1 Commits

Author SHA1 Message Date
user
f2af55e4a5 docs: update README schema section to match actual database schema
All checks were successful
check / check (push) Successful in 1m13s
Update the Schema section and related references throughout README.md to
accurately reflect the current 001_initial.sql migration:

- Rename 'users' table to 'sessions' with new columns: uuid, password_hash,
  signing_key, away_message
- Add new 'clients' table (uuid, session_id FK, token, created_at, last_seen)
- Add topic_set_by and topic_set_at columns to 'channels' table
- Update channel_members FK from user_id to session_id
- Add params column to messages table
- Update client_queues FK from user_id to client_id
- Update Queue Architecture diagram labels and surrounding text
- Update In-Memory Broker description to use client_id terminology
- Update Multi-Client Model MVP note to reflect sessions/clients split
2026-03-17 04:55:41 -07:00
18 changed files with 752 additions and 2075 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -7,21 +7,10 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
all: check build
# ensure-web-dist creates placeholder files so //go:embed dist/* in
# web/embed.go resolves without a full Node.js build. The real SPA is
# built by the web-builder Docker stage; these placeholders let
# "make test" and "make build" work outside Docker.
ensure-web-dist:
@if [ ! -d web/dist ]; then \
mkdir -p web/dist && \
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
echo "==> Created placeholder web/dist/ for go:embed"; \
fi
build: ensure-web-dist
build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
lint: ensure-web-dist
lint:
golangci-lint run --config .golangci.yml ./...
fmt:
@@ -31,7 +20,7 @@ fmt:
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: ensure-web-dist
test:
go test -timeout 30s -v -race -cover ./...
# check runs all validation without making changes

507
README.md
View File

@@ -115,7 +115,7 @@ Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
are unique per server. Identity starts with a key — a nick is a display name.
Accounts are optional: you can create an anonymous session instantly, or
set a password via the PASS command for multi-client access to a single session.
register with a password for multi-client access to a single session.
### On the resemblance to JSON-RPC
@@ -149,59 +149,63 @@ not arbitrary choices — each one follows from the project's core thesis that
IRC's command model is correct and only the transport and session management
need to change.
### Identity & Sessions — Cookie-Based Authentication
### Identity & Sessions — Dual Authentication Model
The server uses **HTTP cookies** for all authentication. There is no separate
registration step — sessions start anonymous and can optionally set a password
for multi-client access.
The server supports two authentication paths: **anonymous sessions** for
instant access, and **optional account registration** for multi-client access.
#### Session Creation
#### Anonymous Sessions (No Account Required)
The simplest entry point. No registration, no passwords.
- **Session creation**: client sends `POST /api/v1/session` with a desired
nick → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing
a cryptographically random token (64 hex characters) and returns the user
ID and nick in the JSON response body. No token appears in the JSON body.
- The auth cookie is HttpOnly, SameSite=Strict, and Secure when behind TLS.
Clients never need to handle the token directly — the browser/HTTP client
manages cookies automatically.
- Sessions start anonymous — no password required. When the session expires
or the user QUITs, the nick is released.
nick → server assigns an **auth token** (64 hex characters of
cryptographically random bytes) and returns the user ID, nick, and token.
- The auth token implicitly identifies the client. Clients present it via
`Authorization: Bearer <token>`.
- Anonymous sessions are ephemeral — when the session expires or the user
QUITs, the nick is released and there is no way to reclaim it.
#### Setting a Password (Optional, for Multi-Client Access)
#### Registered Accounts (Optional)
For users who want to access the same session from multiple devices:
For users who want multi-client access (multiple devices sharing one session):
- **Set password via IRC PASS command**: the authenticated client sends
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`.
The server hashes the password with bcrypt and stores it on the session.
Password must be at least 8 characters.
- **Login from another client**: `POST /api/v1/login` with nick and password →
server verifies the password, creates a new client for the existing session,
and sets an auth cookie. Channel memberships and message queues are shared.
Login only works while the session still exists — if all clients have logged
out or the user has sent QUIT, the session is deleted and the password is
lost.
- **Registration**: client sends `POST /api/v1/register` with a nick and
password (minimum 8 characters) → server creates a session with the
password hashed via bcrypt, and returns the user ID, nick, and auth token.
- **Login**: client sends `POST /api/v1/login` with nick and password →
server verifies the password against the stored bcrypt hash and creates a
new client token for the existing session. This enables multi-client
access: logging in from a new device adds a client to the existing session
rather than creating a new one, so channel memberships and message queues
are shared. Note: login only works while the session still exists — if all
clients have logged out or the user has sent QUIT, the session is deleted
and the registration is lost.
- Registered accounts cannot be logged into via `POST /api/v1/session`
that endpoint is for anonymous sessions only.
- Anonymous sessions (created via `/session`) cannot be logged into via
`/login` because they have no password set.
#### Common Properties
#### Common Properties (Both Paths)
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
the stable identity.
- Server-assigned IDs — clients do not choose their own IDs.
- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry
encoded in the token, no client-side decode. The server is the sole authority
on cookie validity.
- Tokens are opaque random bytes, **not JWTs**. No claims, no expiry encoded
in the token, no client-side decode. The server is the sole authority on
token validity.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
Anonymous sessions preserve that simplicity — instant access, zero friction.
But some users want to access the same session from multiple devices without
a bouncer. The PASS command enables multi-client login without adding friction
for casual users: if you don't need multi-client, just create a session and
go. Cookie-based auth eliminates token management from client code entirely —
browsers and HTTP cookie jars handle it automatically. Note: both anonymous
and password-protected sessions are deleted when the last client disconnects
(QUIT or logout). Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent of
password status.
a bouncer. Optional registration with password enables multi-client login
without adding friction for casual users: if you don't want an account,
don't create one. Note: in the current implementation, both anonymous and
registered sessions are deleted when the last client disconnects (QUIT or
logout); registration does not make a session survive all-client
removal. Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent
of account registration.
### Nick Semantics
@@ -228,7 +232,7 @@ A single user session can have multiple clients (phone, laptop, terminal).
- The server fans out all S2C messages to every active client queue for that
user session.
- `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth cookie.
identified by the auth token.
- Client queues have **independent expiry/pruning** — one client going offline
doesn't affect others.
@@ -240,10 +244,11 @@ User Session
```
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
client to an existing session (one that has a password set via PASS command),
enabling true multi-client support (multiple cookies sharing one nick/session
with independent message queues). Sessions without a password cannot be
logged into.
client to an existing registered session, enabling true multi-client support
(multiple tokens sharing one nick/session with independent message queues).
Anonymous sessions created via `POST /api/v1/session` always create a new
user with a new nick. A future endpoint to "add a client to an existing
anonymous session" is planned but not yet implemented.
**Rationale:** The fundamental IRC mobile problem is that you can't have your
phone and laptop connected simultaneously without a bouncer. Server-side
@@ -350,22 +355,19 @@ over binary is measured in bytes per message, not meaningful bandwidth. The
canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
matters for signing.
### Why Opaque Cookies Instead of JWTs
### Why Opaque Tokens Instead of JWTs
JWTs encode claims that clients can decode and potentially rely on. This
creates a coupling between token format and client behavior. If the server
needs to revoke a token, change the expiry model, or add/remove claims, JWT
clients may break or behave incorrectly.
Opaque auth cookies are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
sets raw hex as an HttpOnly cookie
- On each request, server hashes the cookie value and looks it up
- Revocation is a database delete (cookie becomes invalid immediately)
Opaque tokens are simpler:
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
- Client presents the raw token; server hashes and looks it up
- Revocation is a database delete
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
- Cookie format can change without breaking clients
- Clients never handle tokens directly — browsers and HTTP cookie jars
manage everything automatically
- Token format can change without breaking clients
---
@@ -389,18 +391,17 @@ The entire read/write loop for a client is two endpoints. Everything else
### Session Lifecycle
#### Session Creation
#### Anonymous Session
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session {"nick":"alice"} │
│ → Set-Cookie: neoirc_auth=<token>; HttpOnly; ... │
│ → {"id":1, "nick":"alice"} │
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."}
│ │
│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │
│ → {"status":"joined","channel":"#general"} │
│ (Cookie sent automatically on all subsequent requests)
│ (Server fans out JOIN event to all #general members)
│ │
│ 3. POST /api/v1/messages {"command":"PRIVMSG", │
│ "to":"#general","body":["hello"]} │
@@ -417,37 +418,31 @@ The entire read/write loop for a client is two endpoints. Everything else
│ 6. POST /api/v1/messages {"command":"QUIT"} │
│ → {"status":"quit"} │
│ (Server broadcasts QUIT, removes from channels, │
│ deletes session, releases nick, clears cookie)
│ deletes session, releases nick)
│ │
└────────────────────────────────────────────────────────────┘
```
#### Multi-Client via Password
#### Registered Account
```
┌─ Client A ────────────────────────────────────────────────┐
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/session {"nick":"alice"}
→ Set-Cookie: neoirc_auth=<token_a>; HttpOnly; ...
│ → {"id":1, "nick":"alice"}
│ 2. POST /api/v1/messages │
│ {"command":"PASS","body":["s3cret!!"]} │
│ → {"status":"ok"} │
│ (Password set via IRC PASS command) │
│ 1. POST /api/v1/register
{"nick":"alice", "password":"s3cret!!"}
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."}
(Session created with bcrypt-hashed password)
│ │
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
│ │
└────────────────────────────────────────────────────────────┘
┌─ Client B (another device, while session is still active) ┐
│ (From another device, while session is still active) │
│ │
3. POST /api/v1/login │
2. POST /api/v1/login │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → Set-Cookie: neoirc_auth=<token_b>; HttpOnly; ...
│ → {"id":1, "nick":"alice"} │
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."}
│ (New client added to existing session — channels │
│ and message queues are preserved.)
│ and message queues are preserved. If all clients
│ have logged out, session no longer exists.) │
│ │
└────────────────────────────────────────────────────────────┘
```
@@ -528,7 +523,7 @@ the same JSON envelope:
| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). |
| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** |
| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. |
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. |
| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. |
**Important invariants:**
@@ -734,35 +729,6 @@ Change the user's nickname.
**IRC reference:** RFC 1459 §4.1.2
#### PASS — Set Session Password
Set a password on the current session, enabling multi-client login via
`POST /api/v1/login`. The password is hashed with bcrypt and stored
server-side.
**C2S:**
```json
{"command": "PASS", "body": ["mypassword"]}
```
**Behavior:**
- `body[0]` is the password. Must be at least 8 characters.
- On success, the server responds with `{"status": "ok"}`.
- If the password is too short or missing, the server sends
ERR_NEEDMOREPARAMS (461) via the message queue.
- Calling PASS again overwrites the previous password.
- Once a password is set, `POST /api/v1/login` can be used with the nick
and password to create additional clients on the same session.
**Response:** `200 OK`
```json
{"status": "ok"}
```
**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for
session password management.
#### TOPIC — Set Channel Topic
Set or change a channel's topic.
@@ -829,7 +795,7 @@ Destroy the session and disconnect from the server.
quitting user. The quitting user does **not** receive their own QUIT.
- The user is removed from all channels.
- Empty channels are deleted (ephemeral).
- The user's session is destroyed — the auth cookie is invalidated, the nick
- The user's session is destroyed — the auth token is invalidated, the nick
is released.
- Subsequent requests with the old token return HTTP 401.
@@ -1047,13 +1013,12 @@ carries IRC-style parameters (e.g., channel name, target nick).
Inspired by IRC, simplified:
| Mode | Name | Meaning |
|------|----------------|---------|
|------|--------------|---------|
| `+i` | Invite-only | Only invited users can join |
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
| `+s` | Secret | Channel hidden from LIST response |
| `+t` | Topic lock | Only operators can change the topic |
| `+n` | No external | Only channel members can send messages to the channel |
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) |
**User channel modes (set per-user per-channel):**
@@ -1064,64 +1029,14 @@ Inspired by IRC, simplified:
**Status:** Channel modes are defined but not yet enforced. The `modes` column
exists in the channels table but the server does not check modes on actions.
Exception: `+H` (hashcash) is fully enforced — see below.
### Per-Channel Hashcash (Anti-Spam)
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
anti-spam mechanism: channel operators set a difficulty level, and clients must
compute a proof-of-work stamp bound to the specific channel and message before
sending.
**Setting the requirement:**
```
MODE #channel +H <bits> — require <bits> leading zero bits (1-40)
MODE #channel -H — disable hashcash requirement
```
**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter`
- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp)
- `YYMMDD` — current date (prevents old token reuse)
- `channel` — channel name (prevents cross-channel reuse)
- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message)
- `counter` — hex nonce
**Sending a message to a hashcash-protected channel:**
Include the hashcash stamp in the `meta` field:
```json
{
"command": "PRIVMSG",
"to": "#general",
"body": ["hello world"],
"meta": {
"hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a"
}
}
```
**Server validation:** The server checks that the stamp is well-formed, meets
the required difficulty, is bound to the correct channel and message body, has a
recent date, and has not been previously used. Spent stamps are cached for 1
year to prevent replay attacks.
**Error responses:** If the channel requires hashcash and the stamp is missing,
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
descriptive reason.
**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)`
to compute stamps. Higher bit counts take exponentially longer to compute.
---
## API Reference
All endpoints accept and return `application/json`. Authenticated endpoints
require the `neoirc_auth` cookie, which is set automatically by
`POST /api/v1/session` and `POST /api/v1/login`.
require `Authorization: Bearer <token>` header. The token is obtained from
`POST /api/v1/session`.
All API responses include appropriate HTTP status codes. Error responses have
the format:
@@ -1150,18 +1065,11 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field.
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
**Response:** `201 Created`
The response sets an `neoirc_auth` HttpOnly cookie containing the auth token.
The JSON body does **not** include the token.
```
Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
```
```json
{
"id": 1,
"nick": "alice"
"nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
}
```
@@ -1169,16 +1077,7 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
|---------|---------|-------------|
| `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick (always matches request on success) |
**Cookie properties:**
| Property | Value |
|------------|-------|
| `Name` | `neoirc_auth` |
| `HttpOnly` | `true` (not accessible from JavaScript) |
| `SameSite` | `Strict` (prevents CSRF) |
| `Secure` | `true` when behind TLS |
| `Path` | `/` |
| `token` | string | 64-character hex auth token. Store this — it's the only credential. |
**Errors:**
@@ -1191,18 +1090,66 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict
**curl example:**
```bash
# Use -c to save cookies, -b to send them
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}'
-d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token)
echo $TOKEN
```
### POST /api/v1/register — Register Account
Create a new user session with a password. The password is hashed
with bcrypt and stored server-side. The password enables login from
additional clients via `POST /api/v1/login` while the session
remains active.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `password` | string | Yes | Minimum 8 characters |
**Response:** `201 Created`
```json
{
"id": 1,
"nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick |
| `token` | string | 64-character hex auth token |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid nick format` | Nick doesn't match allowed format |
| 400 | `password must be at least 8 characters` | Password too short |
| 409 | `nick already taken` | Another active session holds this nick |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
```
### POST /api/v1/login — Login to Account
Authenticate with a nick and password (set via the PASS IRC command). Creates a
new client for the existing session, preserving channel memberships and message
queues. This is how multi-client access works: each login adds a new client to
the session with its own auth cookie and message delivery queue.
Authenticate with a previously registered nick and password. Creates a new
client token for the existing session, preserving channel memberships and
message queues. This is how multi-client access works for registered accounts:
each login adds a new client to the session.
On successful login, the server enqueues MOTD messages and synthetic channel
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
@@ -1215,37 +1162,37 @@ new client's queue, so the client can immediately restore its UI state.
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | Must match an active session with a password set |
| `password` | string | Yes | Must match the session's password |
| `nick` | string | Yes | Must match a registered account |
| `password` | string | Yes | Must match the account's password |
**Response:** `200 OK`
The response sets an `neoirc_auth` HttpOnly cookie for the new client.
```json
{
"id": 1,
"nick": "alice"
"nick": "alice",
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Session ID |
| `id` | integer | Session ID (same as when registered) |
| `nick` | string | Current nick |
| `token` | string | New 64-character hex auth token for this client |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `nick and password required` | Missing nick or password |
| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set |
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
**curl example:**
```bash
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}'
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
```
### GET /api/v1/state — Get Session State
@@ -1289,13 +1236,13 @@ Each channel object:
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/state \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
**Reconnect with channel state initialization:**
```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/messages — Poll Messages (Long-Poll)
@@ -1355,12 +1302,14 @@ real-time endpoint — clients call it in a loop.
**curl example (immediate):**
```bash
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
```
**curl example (long-poll, 15s):**
```bash
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq .
curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
```
### POST /api/v1/messages — Send Command
@@ -1387,7 +1336,6 @@ reference with all required and optional fields.
| `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK |
| `PASS` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK |
| `MODE` | `to` | | 200 OK |
| `NAMES` | `to` | | 200 OK |
@@ -1402,14 +1350,14 @@ All IRC commands return HTTP 200 OK. IRC-level success and error responses
are delivered as **numeric replies** through the message queue (see
[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are
reserved for transport-level problems: malformed JSON (400), missing/invalid
auth cookies (401), and server errors (500).
auth tokens (401), and server errors (500).
**HTTP errors (transport-level only):**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid request` | Malformed JSON or empty command |
| 401 | `unauthorized` | Missing or invalid auth cookie |
| 401 | `unauthorized` | Missing or invalid auth token |
| 500 | `internal error` | Server-side failure |
**IRC numeric error replies (delivered via message queue):**
@@ -1500,11 +1448,11 @@ events). Event messages are delivered via the live queue only.
```bash
# Latest 50 messages in #general
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
# Older messages (pagination)
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/channels — List Channels
@@ -1535,22 +1483,18 @@ List members of a channel. The `{name}` parameter is the channel name
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/channels/general/members \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
### POST /api/v1/logout — Logout
Destroy the current client's session cookie and server-side client record.
If no other clients remain on the session, the user is fully cleaned up:
parted from all channels (with QUIT broadcast to members), session deleted,
nick released. The auth cookie is cleared in the response.
Destroy the current client's auth token. If no other clients remain on the
session, the user is fully cleaned up: parted from all channels (with QUIT
broadcast to members), session deleted, nick released.
**Request:** No body. Requires auth cookie.
**Request:** No body. Requires auth.
**Response:** `200 OK`
The response clears the `neoirc_auth` cookie.
```json
{"status": "ok"}
```
@@ -1559,11 +1503,12 @@ The response clears the `neoirc_auth` cookie.
| Status | Error | When |
|--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth cookie |
| 401 | `unauthorized` | Missing or invalid auth token |
**curl example:**
```bash
curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq .
curl -s -X POST http://localhost:8080/api/v1/logout \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/users/me — Current User Info
@@ -1587,7 +1532,7 @@ Return the current user's session state. This is an alias for
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/users/me \
-b cookies.txt | jq .
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/server — Server Info
@@ -1832,21 +1777,20 @@ authenticity.
### Authentication
- **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of
entropy). Tokens are hashed (SHA-256) before storage and validated on every
request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict
(CSRF protection), and Secure when behind TLS.
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
Tokens are hashed (SHA-256) before storage and validated on every request.
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
password, instant access. The auth cookie is the sole credential.
- **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed
password on the session. `POST /api/v1/login` authenticates against the
stored hash and issues a new client cookie.
password, instant access. The token is the sole credential.
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password
(minimum 8 characters). The password is hashed with bcrypt at the default
cost factor and stored alongside the session. `POST /api/v1/login`
authenticates against the stored hash and issues a new client token.
- **Password security**: Passwords are never stored in plain text. bcrypt
handles salting and key stretching automatically. Sessions without a
password cannot be logged into via `/login`.
- **Cookie security**: Auth cookies should only be transmitted over HTTPS in
production. If a cookie is compromised, the attacker has full access to the
session until QUIT or expiry.
handles salting and key stretching automatically. Anonymous sessions have
an empty `password_hash` and cannot be logged into via `/login`.
- **Token security**: Tokens should be treated like session cookies. Transmit
only over HTTPS in production. If a token is compromised, the attacker has
full access to the session until QUIT or expiry.
### Message Integrity
@@ -1883,10 +1827,8 @@ authenticity.
- **HTTPS is strongly recommended** for production deployments. The server
itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS
termination.
- **CORS**: The server allows all origins with credentials
(`Access-Control-Allow-Credentials: true`), reflecting the request Origin.
This enables cookie-based auth from cross-origin clients. Restrict origins
in production via reverse proxy configuration if needed.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
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
@@ -2076,7 +2018,7 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Client output queue entries**: Pruned automatically when older than
`QUEUE_MAX_AGE` (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Sessions**: Both anonymous and password-protected sessions are deleted on `QUIT`
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
or when the last client logs out (`POST /api/v1/logout` with no remaining
clients triggers session cleanup). There is no distinction between session
types in the cleanup path — `handleQuit` and `cleanupUser` both call
@@ -2237,59 +2179,68 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl
```bash
# 1a. Create a session (cookie saved automatically with -c)
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
# 1a. Create an anonymous session (no account)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser"}'
-d '{"nick":"testuser"}' | jq -r .token)
# 1b. Optionally set a password for multi-client access
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
# 1b. Or register an account (multi-client support)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-H 'Content-Type: application/json' \
-d '{"command":"PASS","body":["mypassword"]}'
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
# 1c. Login from another device (saves new cookie)
curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \
# 1c. Or login to an existing account
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}'
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
# 2. Join a channel
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"JOIN","to":"#general"}'
# 3. Send a message
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
# 4. Poll for messages (one-shot)
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
# 5. Long-poll (blocks up to 15s waiting for messages)
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \
-H "Authorization: Bearer $TOKEN" | jq .
# 6. Send a DM
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
# 7. Change nick
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"NICK","body":["newnick"]}'
# 8. Set channel topic
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
# 9. Leave a channel
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"PART","to":"#general","body":["goodbye"]}'
# 10. Disconnect
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"command":"QUIT","body":["leaving"]}'
```
@@ -2299,25 +2250,27 @@ curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
The key to real-time messaging is the poll loop. Here's the pattern:
```python
# Python example — using requests.Session for automatic cookie handling
import requests, json, time
# Python example
import requests, json
BASE = "http://localhost:8080/api/v1"
session = requests.Session() # Manages cookies automatically
token = None
last_id = 0
# Create session (cookie set automatically via Set-Cookie header)
resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
print(f"Session: {resp.json()}")
# Create session
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
token = resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Join channel
session.post(f"{BASE}/messages",
requests.post(f"{BASE}/messages", headers=headers,
json={"command": "JOIN", "to": "#general"})
# Poll loop
while True:
try:
resp = session.get(f"{BASE}/messages",
resp = requests.get(f"{BASE}/messages",
headers=headers,
params={"after": last_id, "timeout": 15},
timeout=20) # HTTP timeout > long-poll timeout
data = resp.json()
@@ -2334,14 +2287,14 @@ while True:
```
```javascript
// JavaScript/browser example — cookies sent automatically
async function pollLoop() {
// JavaScript/browser example
async function pollLoop(token) {
let lastId = 0;
while (true) {
try {
const resp = await fetch(
`/api/v1/messages?after=${lastId}&timeout=15`,
{credentials: 'same-origin'} // Include cookies
{headers: {'Authorization': `Bearer ${token}`}}
);
if (resp.status === 401) { /* session expired */ break; }
const data = await resp.json();
@@ -2373,8 +2326,8 @@ Clients should handle these message commands from the queue:
### Error Handling
- **HTTP 401**: Auth cookie expired or invalid. Re-create session or
re-login (if a password was set).
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
re-login (registered account).
- **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation, registration, or
NICK change).
@@ -2394,11 +2347,10 @@ Clients should handle these message commands from the queue:
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
if `from` is you, use `to`; if `from` is someone else, use `from`.
5. **Reconnection**: If the poll loop fails with 401, the auth cookie is
invalid. For sessions without a password, create a new session. For
sessions with a password set (via PASS command), log in again via
`POST /api/v1/login` to get a fresh cookie on the same session. If it
fails with a network error, retry with backoff.
5. **Reconnection**: If the poll loop fails with 401, the token is invalid.
For anonymous sessions, create a new session. For registered accounts,
log in again via `POST /api/v1/login` to get a fresh token on the same
session. If it fails with a network error, retry with backoff.
---
@@ -2557,10 +2509,8 @@ creating one session pays once and keeps their session.
331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
- [ ] **Max message size enforcement** — reject oversized messages
- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag)
- [x] **Multi-client sessions** — set a password via PASS command, then
login from additional devices via `POST /api/v1/login`
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
all API authentication
- [ ] **Multi-client sessions** — add client to existing session
(share nick across devices)
### Future (1.0+)
@@ -2673,12 +2623,13 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the
API is too complex.
2. **Passwords optional** — anonymous sessions are instant: pick a nick and
2. **Accounts optional** — anonymous sessions are instant: pick a nick and
talk. No registration, no email verification. The cost of entry is a
hashcash proof, not bureaucracy. For users who want multi-client access
(multiple devices sharing one session), the PASS command sets a password
on the session — but it's never required. Identity verification at the
message layer uses cryptographic signing, independent of password status.
(multiple devices sharing one session), optional account registration
with password is available — but never required. Identity
verification at the message layer uses cryptographic signing,
independent of account status.
3. **IRC semantics over HTTP** — command names and numeric codes from
RFC 1459/2812. If you've built an IRC client or bot, you already know the

View File

@@ -9,7 +9,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
@@ -29,19 +28,16 @@ var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the neoirc server API.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient creates a new API client with a cookie jar
// for automatic auth cookie management.
// NewClient creates a new API client.
func NewClient(baseURL string) *Client {
jar, _ := cookiejar.New(nil)
return &Client{
return &Client{ //nolint:exhaustruct // Token set after CreateSession
BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: httpTimeout,
Jar: jar,
},
}
}
@@ -83,6 +79,8 @@ func (client *Client) CreateSession(
return nil, fmt.Errorf("decode session: %w", err)
}
client.Token = resp.Token
return &resp, nil
}
@@ -123,7 +121,6 @@ func (client *Client) PollMessages(
Timeout: time.Duration(
timeout+pollExtraTime,
) * time.Second,
Jar: client.HTTPClient.Jar,
}
params := url.Values{}
@@ -148,6 +145,10 @@ func (client *Client) PollMessages(
return nil, fmt.Errorf("new request: %w", err)
}
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
resp, err := pollClient.Do(request)
if err != nil {
return nil, fmt.Errorf("poll request: %w", err)
@@ -303,6 +304,12 @@ func (client *Client) do(
"Content-Type", "application/json",
)
if client.Token != "" {
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
}
resp, err := client.HTTPClient.Do(request)
if err != nil {
return nil, fmt.Errorf("http: %w", err)

View File

@@ -7,8 +7,6 @@ import (
"fmt"
"math/big"
"time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (
@@ -39,23 +37,6 @@ func MintHashcash(bits int, resource string) string {
}
}
// MintChannelHashcash computes a hashcash stamp bound to
// a specific channel and message body. The stamp format
// is 1:bits:YYMMDD:channel:bodyhash:counter where
// bodyhash is the hex-encoded SHA-256 of the message
// body bytes. Delegates to the internal/hashcash package.
func MintChannelHashcash(
bits int,
channel string,
body []byte,
) string {
bodyHash := hashcash.BodyHash(body)
return hashcash.MintChannelStamp(
bits, channel, bodyHash,
)
}
// hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits.
func hasLeadingZeroBits(

View File

@@ -12,6 +12,7 @@ type SessionRequest struct {
type SessionResponse struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Token string `json:"token"`
}
// StateResponse is the response from GET /api/v1/state.

View File

@@ -16,28 +16,80 @@ var errNoPassword = errors.New(
"account has no password set",
)
// SetPassword sets a bcrypt-hashed password on a session,
// enabling multi-client login via POST /api/v1/login.
func (database *Database) SetPassword(
// RegisterUser creates a session with a hashed password
// and returns session ID, client ID, and token.
func (database *Database) RegisterUser(
ctx context.Context,
sessionID int64,
password string,
) error {
nick, password string,
) (int64, int64, string, error) {
hash, err := bcrypt.GenerateFromPassword(
[]byte(password), bcryptCost,
)
if err != nil {
return fmt.Errorf("hash password: %w", err)
return 0, 0, "", fmt.Errorf(
"hash password: %w", err,
)
}
_, err = database.conn.ExecContext(ctx,
"UPDATE sessions SET password_hash = ? WHERE id = ?",
string(hash), sessionID)
sessionUUID := uuid.New().String()
clientUUID := uuid.New().String()
token, err := generateToken()
if err != nil {
return fmt.Errorf("set password: %w", err)
return 0, 0, "", err
}
return nil
now := time.Now()
transaction, err := database.conn.BeginTx(ctx, nil)
if err != nil {
return 0, 0, "", fmt.Errorf(
"begin tx: %w", err,
)
}
res, err := transaction.ExecContext(ctx,
`INSERT INTO sessions
(uuid, nick, password_hash,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
sessionUUID, nick, string(hash), now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create session: %w", err,
)
}
sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients
(uuid, session_id, token,
created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now)
if err != nil {
_ = transaction.Rollback()
return 0, 0, "", fmt.Errorf(
"create client: %w", err,
)
}
clientID, _ := clientRes.LastInsertId()
err = transaction.Commit()
if err != nil {
return 0, 0, "", fmt.Errorf(
"commit registration: %w", err,
)
}
return sessionID, clientID, token, nil
}
// LoginUser verifies a nick/password and creates a new

View File

@@ -6,65 +6,63 @@ import (
_ "modernc.org/sqlite"
)
func TestSetPassword(t *testing.T) {
func TestRegisterUser(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "passuser")
sessionID, clientID, token, err :=
database.RegisterUser(ctx, "reguser", "password123")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "password123",
)
if err != nil {
t.Fatal(err)
}
// Verify we can now log in with the password.
loginSID, loginCID, loginToken, err :=
database.LoginUser(ctx, "passuser", "password123")
if err != nil {
t.Fatal(err)
}
if loginSID == 0 || loginCID == 0 || loginToken == "" {
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
// Verify session works via token lookup.
sid, cid, nick, err :=
database.GetSessionByToken(ctx, token)
if err != nil {
t.Fatal(err)
}
if sid != sessionID || cid != clientID {
t.Fatal("session/client id mismatch")
}
if nick != "reguser" {
t.Fatalf("expected reguser, got %s", nick)
}
}
func TestSetPasswordThenWrongLogin(t *testing.T) {
func TestRegisterUserDuplicateNick(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "wrongpw")
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "dupnick", "password123")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "correctpass",
)
if err != nil {
t.Fatal(err)
_ = regSID
_ = regCID
_ = regToken
dupSID, dupCID, dupToken, dupErr :=
database.RegisterUser(ctx, "dupnick", "other12345")
if dupErr == nil {
t.Fatal("expected error for duplicate nick")
}
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
_ = dupSID
_ = dupCID
_ = dupToken
}
func TestLoginUser(t *testing.T) {
@@ -73,26 +71,23 @@ func TestLoginUser(t *testing.T) {
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err :=
database.CreateSession(ctx, "loginuser")
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
err = database.SetPassword(
ctx, sessionID, "mypassword",
)
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, token, err :=
sessionID, clientID, token, err :=
database.LoginUser(ctx, "loginuser", "mypassword")
if err != nil {
t.Fatal(err)
}
if loginSID == 0 || loginCID == 0 || token == "" {
if sessionID == 0 || clientID == 0 || token == "" {
t.Fatal("expected valid ids and token")
}
@@ -108,6 +103,33 @@ func TestLoginUser(t *testing.T) {
}
}
func TestLoginUserWrongPassword(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
regSID, regCID, regToken, err :=
database.RegisterUser(ctx, "wrongpw", "correctpass")
if err != nil {
t.Fatal(err)
}
_ = regSID
_ = regCID
_ = regToken
loginSID, loginCID, loginToken, loginErr :=
database.LoginUser(ctx, "wrongpw", "wrongpass12")
if loginErr == nil {
t.Fatal("expected error for wrong password")
}
_ = loginSID
_ = loginCID
_ = loginToken
}
func TestLoginUserNoPassword(t *testing.T) {
t.Parallel()

View File

@@ -1305,110 +1305,3 @@ func (database *Database) GetQueueEntryCount(
return count, nil
}
// GetChannelHashcashBits returns the hashcash difficulty
// requirement for a channel. Returns 0 if not set.
func (database *Database) GetChannelHashcashBits(
ctx context.Context,
channelID int64,
) (int, error) {
var bits int
err := database.conn.QueryRowContext(
ctx,
"SELECT hashcash_bits FROM channels WHERE id = ?",
channelID,
).Scan(&bits)
if err != nil {
return 0, fmt.Errorf(
"get channel hashcash bits: %w", err,
)
}
return bits, nil
}
// SetChannelHashcashBits sets the hashcash difficulty
// requirement for a channel. A value of 0 disables the
// requirement.
func (database *Database) SetChannelHashcashBits(
ctx context.Context,
channelID int64,
bits int,
) error {
_, err := database.conn.ExecContext(ctx,
`UPDATE channels
SET hashcash_bits = ?, updated_at = ?
WHERE id = ?`,
bits, time.Now(), channelID)
if err != nil {
return fmt.Errorf(
"set channel hashcash bits: %w", err,
)
}
return nil
}
// RecordSpentHashcash stores a spent hashcash stamp hash
// for replay prevention.
func (database *Database) RecordSpentHashcash(
ctx context.Context,
stampHash string,
) error {
_, err := database.conn.ExecContext(ctx,
`INSERT OR IGNORE INTO spent_hashcash
(stamp_hash, created_at)
VALUES (?, ?)`,
stampHash, time.Now())
if err != nil {
return fmt.Errorf(
"record spent hashcash: %w", err,
)
}
return nil
}
// IsHashcashSpent checks whether a hashcash stamp hash
// has already been used.
func (database *Database) IsHashcashSpent(
ctx context.Context,
stampHash string,
) (bool, error) {
var count int
err := database.conn.QueryRowContext(ctx,
`SELECT COUNT(*) FROM spent_hashcash
WHERE stamp_hash = ?`,
stampHash,
).Scan(&count)
if err != nil {
return false, fmt.Errorf(
"check spent hashcash: %w", err,
)
}
return count > 0, nil
}
// PruneSpentHashcash deletes spent hashcash tokens older
// than the cutoff and returns the number of rows removed.
func (database *Database) PruneSpentHashcash(
ctx context.Context,
cutoff time.Time,
) (int64, error) {
res, err := database.conn.ExecContext(ctx,
"DELETE FROM spent_hashcash WHERE created_at < ?",
cutoff,
)
if err != nil {
return 0, fmt.Errorf(
"prune spent hashcash: %w", err,
)
}
deleted, _ := res.RowsAffected()
return deleted, nil
}

View File

@@ -33,7 +33,6 @@ CREATE TABLE IF NOT EXISTS channels (
topic TEXT NOT NULL DEFAULT '',
topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -62,14 +61,6 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
-- Spent hashcash tokens for replay prevention (1-year TTL)
CREATE TABLE IF NOT EXISTS spent_hashcash (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stamp_hash TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_spent_hashcash_created ON spent_hashcash(created_at);
-- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -3,7 +3,6 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
@@ -12,16 +11,10 @@ import (
"time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/go-chi/chi/v5"
)
var (
errHashcashRequired = errors.New("hashcash required")
errHashcashReused = errors.New("hashcash reused")
)
var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
)
@@ -36,7 +29,6 @@ const (
defaultMaxBodySize = 4096
defaultHistLimit = 50
maxHistLimit = 500
authCookieName = "neoirc_auth"
)
func (hdlr *Handlers) maxBodySize() int64 {
@@ -47,18 +39,23 @@ func (hdlr *Handlers) maxBodySize() int64 {
return defaultMaxBodySize
}
// authSession extracts the session from the auth cookie.
// authSession extracts the session from the client token.
func (hdlr *Handlers) authSession(
request *http.Request,
) (int64, int64, string, error) {
cookie, err := request.Cookie(authCookieName)
if err != nil || cookie.Value == "" {
auth := request.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return 0, 0, "", errUnauthorized
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
return 0, 0, "", errUnauthorized
}
sessionID, clientID, nick, err :=
hdlr.params.Database.GetSessionByToken(
request.Context(), cookie.Value,
request.Context(), token,
)
if err != nil {
return 0, 0, "", fmt.Errorf("auth: %w", err)
@@ -67,46 +64,6 @@ func (hdlr *Handlers) authSession(
return sessionID, clientID, nick, nil
}
// setAuthCookie sets the authentication cookie on the
// response.
func (hdlr *Handlers) setAuthCookie(
writer http.ResponseWriter,
request *http.Request,
token string,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// clearAuthCookie removes the authentication cookie from
// the client.
func (hdlr *Handlers) clearAuthCookie(
writer http.ResponseWriter,
request *http.Request,
) {
secure := request.TLS != nil ||
request.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields
Name: authCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func (hdlr *Handlers) requireAuth(
writer http.ResponseWriter,
request *http.Request,
@@ -131,11 +88,10 @@ func (hdlr *Handlers) fanOut(
request *http.Request,
command, from, target string,
body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64,
) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
request.Context(), command, from, target, nil, body, meta,
request.Context(), command, from, target, nil, body, nil,
)
if err != nil {
return "", fmt.Errorf("insert message: %w", err)
@@ -161,11 +117,10 @@ func (hdlr *Handlers) fanOutSilent(
request *http.Request,
command, from, target string,
body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64,
) error {
_, err := hdlr.fanOut(
request, command, from, target, body, meta, sessionIDs,
request, command, from, target, body, sessionIDs,
)
return err
@@ -262,11 +217,10 @@ func (hdlr *Handlers) handleCreateSession(
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
@@ -340,7 +294,7 @@ func (hdlr *Handlers) deliverWelcome(
[]string{
"CHANTYPES=#",
"NICKLEN=32",
"CHANMODES=,,H," + "imnst",
"CHANMODES=,,," + "imnst",
"NETWORK=neoirc",
"CASEMAPPING=ascii",
},
@@ -871,7 +825,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
writer, request,
sessionID, clientID, nick,
payload.Command, payload.To,
payload.Body, payload.Meta, bodyLines,
payload.Body, bodyLines,
)
}
}
@@ -882,7 +836,6 @@ func (hdlr *Handlers) dispatchCommand(
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string,
) {
switch command {
@@ -895,7 +848,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg(
writer, request,
sessionID, clientID, nick,
command, target, body, meta, bodyLines,
command, target, body, bodyLines,
)
case irc.CmdJoin:
hdlr.handleJoin(
@@ -912,11 +865,6 @@ func (hdlr *Handlers) dispatchCommand(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdPass:
hdlr.handlePass(
writer, request,
sessionID, clientID, nick, bodyLines,
)
case irc.CmdTopic:
hdlr.handleTopic(
writer, request,
@@ -1001,7 +949,6 @@ func (hdlr *Handlers) handlePrivmsg(
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string,
) {
if target == "" {
@@ -1039,7 +986,7 @@ func (hdlr *Handlers) handlePrivmsg(
hdlr.handleChannelMsg(
writer, request,
sessionID, clientID, nick,
command, target, body, meta,
command, target, body,
)
return
@@ -1048,7 +995,7 @@ func (hdlr *Handlers) handlePrivmsg(
hdlr.handleDirectMsg(
writer, request,
sessionID, clientID, nick,
command, target, body, meta,
command, target, body,
)
}
@@ -1079,7 +1026,6 @@ func (hdlr *Handlers) handleChannelMsg(
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
) {
chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target,
@@ -1120,180 +1066,16 @@ func (hdlr *Handlers) handleChannelMsg(
return
}
hashcashErr := hdlr.validateChannelHashcash(
request, clientID, sessionID,
writer, nick, target, body, meta, chID,
)
if hashcashErr != nil {
return
}
hdlr.sendChannelMsg(
writer, request, command, nick, target,
body, meta, chID,
writer, request, command, nick, target, body, chID,
)
}
// validateChannelHashcash checks whether the channel
// requires hashcash proof-of-work for messages and
// validates the stamp from the message meta field.
// Returns nil on success or if the channel has no
// hashcash requirement. On failure, it sends the
// appropriate IRC error and returns a non-nil error.
func (hdlr *Handlers) validateChannelHashcash(
request *http.Request,
clientID, sessionID int64,
writer http.ResponseWriter,
nick, target string,
body json.RawMessage,
meta json.RawMessage,
chID int64,
) error {
ctx := request.Context()
bits, bitsErr := hdlr.params.Database.GetChannelHashcashBits(
ctx, chID,
)
if bitsErr != nil {
hdlr.log.Error(
"get channel hashcash bits", "error", bitsErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("channel hashcash bits: %w", bitsErr)
}
if bits <= 0 {
return nil
}
stamp := hdlr.extractHashcashFromMeta(meta)
if stamp == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Channel requires hashcash proof-of-work",
)
return errHashcashRequired
}
return hdlr.verifyChannelStamp(
request, writer,
clientID, sessionID,
nick, target, body, stamp, bits,
)
}
// verifyChannelStamp validates a channel hashcash stamp
// and checks for replay attacks.
func (hdlr *Handlers) verifyChannelStamp(
request *http.Request,
writer http.ResponseWriter,
clientID, sessionID int64,
nick, target string,
body json.RawMessage,
stamp string,
bits int,
) error {
ctx := request.Context()
bodyHashStr := hashcash.BodyHash(body)
valErr := hdlr.channelHashcash.ValidateStamp(
stamp, bits, target, bodyHashStr,
)
if valErr != nil {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Invalid hashcash: "+valErr.Error(),
)
return fmt.Errorf("channel hashcash: %w", valErr)
}
stampKey := hashcash.StampHash(stamp)
spent, spentErr := hdlr.params.Database.IsHashcashSpent(
ctx, stampKey,
)
if spentErr != nil {
hdlr.log.Error(
"check spent hashcash", "error", spentErr,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return fmt.Errorf("check spent hashcash: %w", spentErr)
}
if spent {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrCannotSendToChan, nick, []string{target},
"Hashcash stamp already used",
)
return errHashcashReused
}
recordErr := hdlr.params.Database.RecordSpentHashcash(
ctx, stampKey,
)
if recordErr != nil {
hdlr.log.Error(
"record spent hashcash", "error", recordErr,
)
}
return nil
}
// extractHashcashFromMeta parses the meta JSON and
// returns the hashcash stamp string, or empty string
// if not present.
func (hdlr *Handlers) extractHashcashFromMeta(
meta json.RawMessage,
) string {
if len(meta) == 0 {
return ""
}
var metaMap map[string]json.RawMessage
err := json.Unmarshal(meta, &metaMap)
if err != nil {
return ""
}
raw, ok := metaMap["hashcash"]
if !ok {
return ""
}
var stamp string
err = json.Unmarshal(raw, &stamp)
if err != nil {
return ""
}
return stamp
}
func (hdlr *Handlers) sendChannelMsg(
writer http.ResponseWriter,
request *http.Request,
command, nick, target string,
body json.RawMessage,
meta json.RawMessage,
chID int64,
) {
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
@@ -1313,7 +1095,7 @@ func (hdlr *Handlers) sendChannelMsg(
}
msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, meta, memberIDs,
request, command, nick, target, body, memberIDs,
)
if err != nil {
hdlr.log.Error("send message failed", "error", err)
@@ -1337,7 +1119,6 @@ func (hdlr *Handlers) handleDirectMsg(
sessionID, clientID int64,
nick, command, target string,
body json.RawMessage,
meta json.RawMessage,
) {
targetSID, err := hdlr.params.Database.GetSessionByNick(
request.Context(), target,
@@ -1362,7 +1143,7 @@ func (hdlr *Handlers) handleDirectMsg(
}
msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, meta, recipients,
request, command, nick, target, body, recipients,
)
if err != nil {
hdlr.log.Error("send dm failed", "error", err)
@@ -1473,7 +1254,7 @@ func (hdlr *Handlers) executeJoin(
)
_ = hdlr.fanOutSilent(
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
request, irc.CmdJoin, nick, channel, nil, memberIDs,
)
hdlr.deliverJoinNumerics(
@@ -1643,7 +1424,7 @@ func (hdlr *Handlers) handlePart(
)
_ = hdlr.fanOutSilent(
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
request, irc.CmdPart, nick, channel, body, memberIDs,
)
err = hdlr.params.Database.PartChannel(
@@ -1923,7 +1704,7 @@ func (hdlr *Handlers) executeTopic(
)
_ = hdlr.fanOutSilent(
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
request, irc.CmdTopic, nick, channel, body, memberIDs,
)
hdlr.enqueueNumeric(
@@ -2047,8 +1828,6 @@ func (hdlr *Handlers) handleQuit(
request.Context(), sessionID,
)
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "quit"},
http.StatusOK)
@@ -2088,10 +1867,11 @@ func (hdlr *Handlers) handleMode(
return
}
_ = bodyLines
hdlr.handleChannelMode(
writer, request,
sessionID, clientID, nick, channel,
bodyLines,
)
}
@@ -2100,7 +1880,6 @@ func (hdlr *Handlers) handleChannelMode(
request *http.Request,
sessionID, clientID int64,
nick, channel string,
bodyLines func() []string,
) {
ctx := request.Context()
@@ -2117,47 +1896,10 @@ func (hdlr *Handlers) handleChannelMode(
return
}
lines := bodyLines()
if len(lines) > 0 {
hdlr.applyChannelMode(
writer, request,
sessionID, clientID, nick,
channel, chID, lines,
)
return
}
hdlr.queryChannelMode(
writer, request,
sessionID, clientID, nick, channel, chID,
)
}
// queryChannelMode sends RPL_CHANNELMODEIS and
// RPL_CREATIONTIME for a channel. Includes +H if
// the channel has a hashcash requirement.
func (hdlr *Handlers) queryChannelMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
) {
ctx := request.Context()
modeStr := "+n"
bits, bitsErr := hdlr.params.Database.
GetChannelHashcashBits(ctx, chID)
if bitsErr == nil && bits > 0 {
modeStr = fmt.Sprintf("+nH %d", bits)
}
// 324 RPL_CHANNELMODEIS
hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, modeStr}, "",
[]string{channel, "+n"}, "",
)
// 329 RPL_CREATIONTIME
@@ -2182,156 +1924,6 @@ func (hdlr *Handlers) queryChannelMode(
http.StatusOK)
}
// applyChannelMode handles setting channel modes.
// Currently supports +H/-H for hashcash bits.
func (hdlr *Handlers) applyChannelMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
modeArgs []string,
) {
ctx := request.Context()
modeStr := modeArgs[0]
switch modeStr {
case "+H":
hdlr.setHashcashMode(
writer, request,
sessionID, clientID, nick,
channel, chID, modeArgs,
)
case "-H":
hdlr.clearHashcashMode(
writer, request,
sessionID, clientID, nick,
channel, chID,
)
default:
// Unknown or unsupported mode change.
hdlr.enqueueNumeric(
ctx, clientID, irc.ErrUnknownMode, nick,
[]string{modeStr},
"is unknown mode char to me",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "error"},
http.StatusOK)
}
}
const (
// minHashcashBits is the minimum allowed hashcash
// difficulty for channels.
minHashcashBits = 1
// maxHashcashBits is the maximum allowed hashcash
// difficulty for channels.
maxHashcashBits = 40
)
// setHashcashMode handles MODE #channel +H <bits>.
func (hdlr *Handlers) setHashcashMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
modeArgs []string,
) {
ctx := request.Context()
if len(modeArgs) < 2 { //nolint:mnd // +H requires a bits arg
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick, []string{irc.CmdMode},
"Not enough parameters (+H requires bits)",
)
return
}
bits, err := strconv.Atoi(modeArgs[1])
if err != nil || bits < minHashcashBits ||
bits > maxHashcashBits {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrUnknownMode, nick, []string{"+H"},
fmt.Sprintf(
"Invalid hashcash bits (must be %d-%d)",
minHashcashBits, maxHashcashBits,
),
)
return
}
err = hdlr.params.Database.SetChannelHashcashBits(
ctx, chID, bits,
)
if err != nil {
hdlr.log.Error(
"set channel hashcash bits", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick,
[]string{
channel,
fmt.Sprintf("+H %d", bits),
}, "",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// clearHashcashMode handles MODE #channel -H.
func (hdlr *Handlers) clearHashcashMode(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick, channel string,
chID int64,
) {
ctx := request.Context()
err := hdlr.params.Database.SetChannelHashcashBits(
ctx, chID, 0,
)
if err != nil {
hdlr.log.Error(
"clear channel hashcash bits", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, "+n"}, "",
)
hdlr.broker.Notify(sessionID)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}
// handleNames sends NAMES reply for a channel.
func (hdlr *Handlers) handleNames(
writer http.ResponseWriter,
@@ -2851,8 +2443,6 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc {
)
}
hdlr.clearAuthCookie(writer, request)
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)

View File

@@ -22,7 +22,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware"
@@ -42,7 +41,6 @@ const (
apiMessages = "/api/v1/messages"
apiSession = "/api/v1/session"
apiState = "/api/v1/state"
authCookieName = "neoirc_auth"
)
// testServer wraps a test HTTP server with helpers.
@@ -262,7 +260,7 @@ func doRequest(
func doRequestAuth(
t *testing.T,
method, url, cookie string,
method, url, token string,
body io.Reader,
) (*http.Response, error) {
t.Helper()
@@ -280,11 +278,10 @@ func doRequestAuth(
)
}
if cookie != "" {
request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
Name: authCookieName,
Value: cookie,
})
if token != "" {
request.Header.Set(
"Authorization", "Bearer "+token,
)
}
resp, err := http.DefaultClient.Do(request)
@@ -327,19 +324,17 @@ func (tserver *testServer) createSession(
)
}
// Drain the body.
_, _ = io.ReadAll(resp.Body)
// Extract auth cookie from response.
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
return cookie.Value
}
var result struct {
ID int64 `json:"id"`
Token string `json:"token"`
}
tserver.t.Fatal("no auth cookie in response")
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
tserver.t.Fatalf("decode session: %v", decErr)
}
return ""
return result.Token
}
func (tserver *testServer) sendCommand(
@@ -496,10 +491,10 @@ func findNumeric(
func TestCreateSessionValid(t *testing.T) {
tserver := newTestServer(t)
cookie := tserver.createSession("alice")
token := tserver.createSession("alice")
if cookie == "" {
t.Fatal("expected auth cookie")
if token == "" {
t.Fatal("expected token")
}
}
@@ -621,7 +616,7 @@ func TestCreateSessionMalformed(t *testing.T) {
}
}
func TestAuthNoCookie(t *testing.T) {
func TestAuthNoHeader(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState("")
@@ -630,11 +625,11 @@ func TestAuthNoCookie(t *testing.T) {
}
}
func TestAuthBadCookie(t *testing.T) {
func TestAuthBadToken(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState(
"invalid-cookie-12345",
"invalid-token-12345",
)
if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status)
@@ -1831,6 +1826,90 @@ func assertFieldGTE(
}
}
func TestRegisterValid(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "reguser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf(
"expected 201, got %d: %s",
resp.StatusCode, respBody,
)
}
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
if result["nick"] != "reguser" {
t.Fatalf(
"expected reguser, got %v", result["nick"],
)
}
}
func TestRegisterDuplicate(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(map[string]string{
"nick": "dupuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp2.StatusCode)
}
}
func postJSONExpectStatus(
t *testing.T,
tserver *testServer,
@@ -1865,102 +1944,36 @@ func postJSONExpectStatus(
}
}
func TestPassCommand(t *testing.T) {
func TestRegisterShortPassword(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("passuser")
// Drain initial messages.
_, _ = tserver.pollMessages(token, 0)
// Set password via PASS command.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "PASS",
bodyKey: []string{"s3cure_pass"},
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "shortpw", "password": "short",
},
http.StatusBadRequest,
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result[statusKey] != "ok" {
t.Fatalf(
"expected ok, got %v", result[statusKey],
)
}
}
func TestPassCommandShortPassword(t *testing.T) {
func TestRegisterInvalidNick(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("shortpw")
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try short password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: "PASS",
bodyKey: []string{"short"},
postJSONExpectStatus(
t, tserver, "/api/v1/register",
map[string]string{
"nick": "bad nick!",
"password": "password123",
},
http.StatusBadRequest,
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestPassCommandEmpty(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("emptypw")
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try empty password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{commandKey: "PASS"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestLoginValid(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("loginuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"password123"},
})
// Login with nick + password.
loginBody, err := json.Marshal(map[string]string{
// Register first.
regBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
@@ -1968,6 +1981,26 @@ func TestLoginValid(t *testing.T) {
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
// Login.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
@@ -1977,33 +2010,31 @@ func TestLoginValid(t *testing.T) {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
defer func() { _ = resp2.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
if resp2.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body)
t.Fatalf(
"expected 200, got %d: %s",
resp.StatusCode, respBody,
resp2.StatusCode, respBody,
)
}
// Extract auth cookie from login response.
var loginCookie string
var result map[string]any
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
loginCookie = cookie.Value
_ = json.NewDecoder(resp2.Body).Decode(&result)
break
}
if result["token"] == nil || result["token"] == "" {
t.Fatal("expected token in response")
}
if loginCookie == "" {
t.Fatal("expected auth cookie from login")
// Verify token works.
token, ok := result["token"].(string)
if !ok {
t.Fatal("token not a string")
}
// Verify login cookie works for auth.
status, state := tserver.getState(loginCookie)
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
@@ -2019,22 +2050,49 @@ func TestLoginValid(t *testing.T) {
func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: "PASS",
bodyKey: []string{"correctpass1"},
regBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "correctpass1",
})
if err != nil {
t.Fatal(err)
}
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
"nick": "wrongpwuser",
"password": "wrongpass12",
},
http.StatusUnauthorized,
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/register"),
bytes.NewReader(regBody),
)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
loginBody, err := json.Marshal(map[string]string{
"nick": "wrongpwuser", "password": "wrongpass12",
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf(
"expected 401, got %d", resp2.StatusCode,
)
}
}
func TestLoginNonexistentUser(t *testing.T) {
@@ -2050,74 +2108,13 @@ func TestLoginNonexistentUser(t *testing.T) {
)
}
func TestSessionCookie(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(
map[string]string{"nick": "cookietest"},
)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
t.Fatalf(
"expected 201, got %d", resp.StatusCode,
)
}
// Verify Set-Cookie header.
var authCookie *http.Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
authCookie = cookie
break
}
}
if authCookie == nil {
t.Fatal("expected neoirc_auth cookie")
}
if !authCookie.HttpOnly {
t.Fatal("cookie should be HttpOnly")
}
if authCookie.SameSite != http.SameSiteStrictMode {
t.Fatal("cookie should be SameSite=Strict")
}
// Verify JSON body does NOT contain token.
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if _, hasToken := result["token"]; hasToken {
t.Fatal("JSON body should not contain token")
}
}
func TestSessionStillWorks(t *testing.T) {
tserver := newTestServer(t)
// Verify anonymous session creation still works.
token := tserver.createSession("anon_user")
if token == "" {
t.Fatal("expected cookie for anonymous session")
t.Fatal("expected token for anonymous session")
}
status, state := tserver.getState(token)
@@ -2160,397 +2157,3 @@ func TestNickBroadcastToChannels(t *testing.T) {
)
}
}
// --- Channel Hashcash Tests ---
const (
metaKey = "meta"
modeCmd = "MODE"
hashcashKey = "hashcash"
)
func mintTestChannelHashcash(
tb testing.TB,
bits int,
channel string,
body json.RawMessage,
) string {
tb.Helper()
bodyHash := hashcash.BodyHash(body)
return hashcash.MintChannelStamp(bits, channel, bodyHash)
}
func TestChannelHashcashSetMode(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcmode_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hctest",
})
_, lastID := tserver.pollMessages(token, 0)
// Set hashcash bits to 2 via MODE +H.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: modeCmd,
toKey: "#hctest",
bodyKey: []string{"+H", "2"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get RPL_CHANNELMODEIS (324) confirming +H.
if !findNumeric(msgs, "324") {
t.Fatalf(
"expected RPL_CHANNELMODEIS (324), got %v",
msgs,
)
}
}
func TestChannelHashcashQueryMode(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcquery_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcquery",
})
// Set hashcash bits.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcquery",
bodyKey: []string{"+H", "5"},
})
_, lastID := tserver.pollMessages(token, 0)
// Query mode — should show +nH.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcquery",
})
msgs, _ := tserver.pollMessages(token, lastID)
found := false
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == 324 {
found = true
}
}
if !found {
t.Fatalf(
"expected RPL_CHANNELMODEIS (324), got %v",
msgs,
)
}
}
func TestChannelHashcashClearMode(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcclear_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcclear",
})
// Set hashcash bits.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcclear",
bodyKey: []string{"+H", "5"},
})
// Clear hashcash bits.
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcclear",
bodyKey: []string{"-H"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
// Now message should succeed without hashcash.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcclear",
bodyKey: []string{"test message"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
}
func TestChannelHashcashRejectNoStamp(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcreject_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcreject",
})
// Set hashcash requirement.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcreject",
bodyKey: []string{"+H", "2"},
})
_, lastID := tserver.pollMessages(token, 0)
// Send message without hashcash — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcreject",
bodyKey: []string{"spam message"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get ERR_CANNOTSENDTOCHAN (404).
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
msgs,
)
}
}
func TestChannelHashcashAcceptValidStamp(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcaccept_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcaccept",
})
// Set hashcash requirement (2 bits = fast to mint).
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcaccept",
bodyKey: []string{"+H", "2"},
})
_, lastID := tserver.pollMessages(token, 0)
// Mint a valid hashcash stamp.
msgBody, marshalErr := json.Marshal(
[]string{"hello world"},
)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stamp := mintTestChannelHashcash(
t, 2, "#hcaccept", msgBody,
)
// Send message with valid hashcash.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcaccept",
bodyKey: []string{"hello world"},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id for valid hashcash")
}
// Verify the message was delivered.
msgs, _ := tserver.pollMessages(token, lastID)
if !findMessage(msgs, privmsgCmd, "hcaccept_user") {
t.Fatalf(
"message not received: %v", msgs,
)
}
}
func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcreplay_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcreplay",
})
// Set hashcash requirement.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcreplay",
bodyKey: []string{"+H", "2"},
})
_, _ = tserver.pollMessages(token, 0)
// Mint and send once — should succeed.
msgBody, marshalErr := json.Marshal(
[]string{"unique msg"},
)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stamp := mintTestChannelHashcash(
t, 2, "#hcreplay", msgBody,
)
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcreplay",
bodyKey: []string{"unique msg"},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
_, lastID := tserver.pollMessages(token, 0)
// Replay the same stamp — should fail.
status, _ = tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcreplay",
bodyKey: []string{"unique msg"},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get ERR_CANNOTSENDTOCHAN (404).
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected replay rejection (404), got %v",
msgs,
)
}
}
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcnone_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#nohashcash",
})
// No hashcash set — message should work.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#nohashcash",
bodyKey: []string{"free message"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id")
}
}
func TestChannelHashcashInvalidBitsRange(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcbits_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcbits",
})
_, lastID := tserver.pollMessages(token, 0)
// Try to set bits to 0 — should fail.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcbits",
bodyKey: []string{"+H", "0"},
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "472") {
t.Fatalf(
"expected ERR_UNKNOWNMODE (472), got %v",
msgs,
)
}
}
func TestChannelHashcashMissingBitsArg(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcnoarg_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcnoarg",
})
_, lastID := tserver.pollMessages(token, 0)
// Try to set +H without bits argument.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcnoarg",
bodyKey: []string{"+H"},
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}

View File

@@ -5,11 +5,120 @@ import (
"net/http"
"strings"
"git.eeqj.de/sneak/neoirc/pkg/irc"
"git.eeqj.de/sneak/neoirc/internal/db"
)
const minPasswordLength = 8
// HandleRegister creates a new user with a password.
func (hdlr *Handlers) HandleRegister() http.HandlerFunc {
return func(
writer http.ResponseWriter,
request *http.Request,
) {
request.Body = http.MaxBytesReader(
writer, request.Body, hdlr.maxBodySize(),
)
hdlr.handleRegister(writer, request)
}
}
func (hdlr *Handlers) handleRegister(
writer http.ResponseWriter,
request *http.Request,
) {
type registerRequest struct {
Nick string `json:"nick"`
Password string `json:"password"`
}
var payload registerRequest
err := json.NewDecoder(request.Body).Decode(&payload)
if err != nil {
hdlr.respondError(
writer, request,
"invalid request body",
http.StatusBadRequest,
)
return
}
payload.Nick = strings.TrimSpace(payload.Nick)
if !validNickRe.MatchString(payload.Nick) {
hdlr.respondError(
writer, request,
"invalid nick format",
http.StatusBadRequest,
)
return
}
if len(payload.Password) < minPasswordLength {
hdlr.respondError(
writer, request,
"password must be at least 8 characters",
http.StatusBadRequest,
)
return
}
sessionID, clientID, token, err :=
hdlr.params.Database.RegisterUser(
request.Context(),
payload.Nick,
payload.Password,
)
if err != nil {
hdlr.handleRegisterError(
writer, request, err,
)
return
}
hdlr.stats.IncrSessions()
hdlr.stats.IncrConnections()
hdlr.deliverMOTD(request, clientID, sessionID, payload.Nick)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusCreated)
}
func (hdlr *Handlers) handleRegisterError(
writer http.ResponseWriter,
request *http.Request,
err error,
) {
if db.IsUniqueConstraintError(err) {
hdlr.respondError(
writer, request,
"nick already taken",
http.StatusConflict,
)
return
}
hdlr.log.Error(
"register user failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
}
// HandleLogin authenticates a user with nick and password.
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
return func(
@@ -86,66 +195,9 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick,
)
hdlr.setAuthCookie(writer, request, token)
hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID,
"nick": payload.Nick,
"token": token,
}, http.StatusOK)
}
// handlePass handles the IRC PASS command to set a
// password on the authenticated session, enabling
// multi-client login via POST /api/v1/login.
func (hdlr *Handlers) handlePass(
writer http.ResponseWriter,
request *http.Request,
sessionID, clientID int64,
nick string,
bodyLines func() []string,
) {
lines := bodyLines()
if len(lines) == 0 || lines[0] == "" {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Not enough parameters",
)
return
}
password := lines[0]
if len(password) < minPasswordLength {
hdlr.respondIRCError(
writer, request, clientID, sessionID,
irc.ErrNeedMoreParams, nick,
[]string{irc.CmdPass},
"Password must be at least 8 characters",
)
return
}
err := hdlr.params.Database.SetPassword(
request.Context(), sessionID, password,
)
if err != nil {
hdlr.log.Error(
"set password failed", "error", err,
)
hdlr.respondError(
writer, request,
"internal error",
http.StatusInternalServerError,
)
return
}
hdlr.respondJSON(writer, request,
map[string]string{"status": "ok"},
http.StatusOK)
}

View File

@@ -36,11 +36,6 @@ type Params struct {
const defaultIdleTimeout = 30 * 24 * time.Hour
// spentHashcashTTL is how long spent hashcash tokens are
// retained for replay prevention. Per issue requirements,
// this is 1 year.
const spentHashcashTTL = 365 * 24 * time.Hour
// Handlers manages HTTP request handling.
type Handlers struct {
params *Params
@@ -48,7 +43,6 @@ type Handlers struct {
hc *healthcheck.Healthcheck
broker *broker.Broker
hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator
stats *stats.Tracker
cancelCleanup context.CancelFunc
}
@@ -69,7 +63,6 @@ func New(
hc: params.Healthcheck,
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(),
stats: params.Stats,
}
@@ -292,20 +285,4 @@ func (hdlr *Handlers) pruneQueuesAndMessages(
)
}
}
// Prune spent hashcash tokens older than 1 year.
hashcashCutoff := time.Now().Add(-spentHashcashTTL)
pruned, err := hdlr.params.Database.
PruneSpentHashcash(ctx, hashcashCutoff)
if err != nil {
hdlr.log.Error(
"spent hashcash pruning failed", "error", err,
)
} else if pruned > 0 {
hdlr.log.Info(
"pruned spent hashcash tokens",
"deleted", pruned,
)
}
}

View File

@@ -1,186 +0,0 @@
package hashcash
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var (
errBodyHashMismatch = errors.New(
"body hash mismatch",
)
errBodyHashMissing = errors.New(
"body hash missing",
)
)
// ChannelValidator checks hashcash stamps for
// per-channel PRIVMSG validation. It verifies that
// stamps are bound to a specific channel and message
// body. Replay prevention is handled externally via
// the database spent_hashcash table for persistence
// across server restarts (1-year TTL).
type ChannelValidator struct{}
// NewChannelValidator creates a ChannelValidator.
func NewChannelValidator() *ChannelValidator {
return &ChannelValidator{}
}
// BodyHash computes the hex-encoded SHA-256 hash of a
// message body for use in hashcash stamp validation.
func BodyHash(body []byte) string {
hash := sha256.Sum256(body)
return hex.EncodeToString(hash[:])
}
// ValidateStamp checks a channel hashcash stamp. It
// verifies the stamp format, difficulty, date, channel
// binding, body hash binding, and proof-of-work. Replay
// detection is NOT performed here — callers must check
// the spent_hashcash table separately.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func (cv *ChannelValidator) ValidateStamp(
stamp string,
requiredBits int,
channel string,
bodyHash string,
) 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]
stampBodyHash := parts[4]
headerErr := validateChannelHeader(
version, bitsStr, resource,
requiredBits, channel,
)
if headerErr != nil {
return headerErr
}
stampTime, parseErr := parseStampDate(dateStr)
if parseErr != nil {
return parseErr
}
timeErr := validateTime(stampTime)
if timeErr != nil {
return timeErr
}
bodyErr := validateBodyHash(
stampBodyHash, bodyHash,
)
if bodyErr != nil {
return bodyErr
}
return validateProof(stamp, requiredBits)
}
// StampHash returns a deterministic hash of a stamp
// string for use as a spent-token key.
func StampHash(stamp string) string {
hash := sha256.Sum256([]byte(stamp))
return hex.EncodeToString(hash[:])
}
func validateChannelHeader(
version, bitsStr, resource string,
requiredBits int,
channel string,
) 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 != channel {
return fmt.Errorf(
"%w: got %q, want %q",
errWrongResource, resource, channel,
)
}
return nil
}
func validateBodyHash(
stampBodyHash, expectedBodyHash string,
) error {
if stampBodyHash == "" {
return errBodyHashMissing
}
if stampBodyHash != expectedBodyHash {
return fmt.Errorf(
"%w: got %q, want %q",
errBodyHashMismatch,
stampBodyHash, expectedBodyHash,
)
}
return nil
}
// MintChannelStamp computes a channel hashcash stamp
// with the given difficulty, channel name, and body hash.
// This is intended for clients to generate stamps before
// sending PRIVMSG to hashcash-protected channels.
//
// Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter.
func MintChannelStamp(
bits int,
channel string,
bodyHash string,
) string {
date := time.Now().UTC().Format(dateFormatShort)
prefix := fmt.Sprintf(
"1:%d:%s:%s:%s:",
bits, date, channel, bodyHash,
)
counter := uint64(0)
for {
stamp := prefix + strconv.FormatUint(counter, 16)
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
counter++
}
}

View File

@@ -1,244 +0,0 @@
package hashcash_test
import (
"crypto/sha256"
"encoding/hex"
"testing"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
)
const (
testChannel = "#general"
testBodyText = `["hello world"]`
)
func testBodyHash() string {
hash := sha256.Sum256([]byte(testBodyText))
return hex.EncodeToString(hash[:])
}
func TestChannelValidateHappyPath(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err != nil {
t.Fatalf("valid channel stamp rejected: %v", err)
}
}
func TestChannelValidateWrongChannel(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, testBits, "#other", bodyHash,
)
if err == nil {
t.Fatal("expected channel mismatch error")
}
}
func TestChannelValidateWrongBodyHash(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
wrongHash := sha256.Sum256([]byte("different body"))
wrongBodyHash := hex.EncodeToString(wrongHash[:])
err := validator.ValidateStamp(
stamp, testBits, testChannel, wrongBodyHash,
)
if err == nil {
t.Fatal("expected body hash mismatch error")
}
}
func TestChannelValidateInsufficientBits(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Mint with 2 bits but require 4.
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
err := validator.ValidateStamp(
stamp, 4, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected insufficient bits error")
}
}
func TestChannelValidateZeroBitsSkips(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
"garbage", 0, "#ch", "abc",
)
if err != nil {
t.Fatalf("zero bits should skip: %v", err)
}
}
func TestChannelValidateBadFormat(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
"not:valid", testBits, testChannel, "abc",
)
if err == nil {
t.Fatal("expected bad format error")
}
}
func TestChannelValidateBadVersion(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
stamp := "2:2:260317:#general:" + bodyHash + ":counter"
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected bad version error")
}
}
func TestChannelValidateExpiredStamp(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Mint with a very old date by manually constructing.
stamp := mintStampWithDate(
t, testBits, testChannel, "200101",
)
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected expired stamp error")
}
}
func TestChannelValidateMissingBodyHash(t *testing.T) {
t.Parallel()
validator := hashcash.NewChannelValidator()
bodyHash := testBodyHash()
// Construct a stamp with empty body hash field.
stamp := mintStampWithDate(
t, testBits, testChannel, todayDate(),
)
// This uses the session-style stamp which has empty
// ext field — body hash is missing.
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err == nil {
t.Fatal("expected missing body hash error")
}
}
func TestBodyHash(t *testing.T) {
t.Parallel()
body := []byte(`["hello world"]`)
bodyHash := hashcash.BodyHash(body)
if len(bodyHash) != 64 {
t.Fatalf(
"expected 64-char hex hash, got %d",
len(bodyHash),
)
}
// Same input should produce same hash.
bodyHash2 := hashcash.BodyHash(body)
if bodyHash != bodyHash2 {
t.Fatal("body hash not deterministic")
}
// Different input should produce different hash.
bodyHash3 := hashcash.BodyHash([]byte("different"))
if bodyHash == bodyHash3 {
t.Fatal("different inputs produced same hash")
}
}
func TestStampHash(t *testing.T) {
t.Parallel()
hash1 := hashcash.StampHash("stamp1")
hash2 := hashcash.StampHash("stamp2")
if hash1 == hash2 {
t.Fatal("different stamps produced same hash")
}
// Same input should be deterministic.
hash1b := hashcash.StampHash("stamp1")
if hash1 != hash1b {
t.Fatal("stamp hash not deterministic")
}
}
func TestMintChannelStamp(t *testing.T) {
t.Parallel()
bodyHash := testBodyHash()
stamp := hashcash.MintChannelStamp(
testBits, testChannel, bodyHash,
)
if stamp == "" {
t.Fatal("expected non-empty stamp")
}
// Validate the minted stamp.
validator := hashcash.NewChannelValidator()
err := validator.ValidateStamp(
stamp, testBits, testChannel, bodyHash,
)
if err != nil {
t.Fatalf("minted stamp failed validation: %v", err)
}
}

View File

@@ -126,23 +126,18 @@ func (mware *Middleware) Logging() func(http.Handler) http.Handler {
}
// CORS returns middleware that handles Cross-Origin Resource Sharing.
// AllowCredentials is true so browsers include cookies in
// cross-origin API requests.
func (mware *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ //nolint:exhaustruct // optional fields
AllowOriginFunc: func(
_ *http.Request, _ string,
) bool {
return true
},
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Content-Type", "X-CSRF-Token",
"Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
AllowCredentials: false,
MaxAge: corsMaxAge,
})
}

View File

@@ -75,6 +75,10 @@ func (srv *Server) setupAPIv1(router chi.Router) {
"/session",
srv.handlers.HandleCreateSession(),
)
router.Post(
"/register",
srv.handlers.HandleRegister(),
)
router.Post(
"/login",
srv.handlers.HandleLogin(),

View File

@@ -11,7 +11,6 @@ const (
CmdNames = "NAMES"
CmdNick = "NICK"
CmdNotice = "NOTICE"
CmdPass = "PASS"
CmdPart = "PART"
CmdPing = "PING"
CmdPong = "PONG"