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
10 changed files with 93 additions and 1495 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 BINARY := neoircd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") 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 all: check build
# ensure-web-dist creates placeholder files so //go:embed dist/* in build:
# 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
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
lint: ensure-web-dist lint:
golangci-lint run --config .golangci.yml ./... golangci-lint run --config .golangci.yml ./...
fmt: fmt:
@@ -31,7 +20,7 @@ fmt:
fmt-check: fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: ensure-web-dist test:
go test -timeout 30s -v -race -cover ./... go test -timeout 30s -v -race -cover ./...
# check runs all validation without making changes # check runs all validation without making changes

164
README.md
View File

@@ -301,8 +301,8 @@ The server implements HTTP long-polling for real-time message delivery:
- The client disconnects (connection closed, no response needed) - The client disconnects (connection closed, no response needed)
**Implementation detail:** The server maintains an in-memory broker with **Implementation detail:** The server maintains an in-memory broker with
per-user notification channels. When a message is enqueued for a user, the per-client notification channels. When a message is enqueued for a client, the
broker closes all waiting channels for that user, waking up any blocked broker closes all waiting channels for that client, waking up any blocked
long-poll handlers. This is O(1) notification — no polling loops, no database long-poll handlers. This is O(1) notification — no polling loops, no database
scanning. scanning.
@@ -460,28 +460,28 @@ The entire read/write loop for a client is two endpoints. Everything else
│ │ │ │ │ │
┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐ ┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐
│client_queue│ │client_queue│ │client_queue│ │client_queue│ │client_queue│ │client_queue│
user_id=1 │ │ user_id=2 │ │ user_id=3 client_id=1│ │ client_id=2│ │ client_id=3│
│ msg_id=N │ │ msg_id=N │ │ msg_id=N │ │ msg_id=N │ │ msg_id=N │ │ msg_id=N │
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
alice bob carol alice bob carol
Each message is stored ONCE. One queue entry per recipient. Each message is stored ONCE. One queue entry per recipient client.
``` ```
The `client_queues` table contains `(user_id, message_id)` pairs. When a The `client_queues` table contains `(client_id, message_id)` pairs. When a
client polls with `GET /messages?after=<queue_id>`, the server queries for client polls with `GET /messages?after=<queue_id>`, the server queries for
queue entries with `id > after` for that user, joins against the messages queue entries with `id > after` for that client, joins against the messages
table, and returns the results. The `queue_id` (auto-incrementing primary table, and returns the results. The `queue_id` (auto-incrementing primary
key of `client_queues`) serves as a monotonically increasing cursor. key of `client_queues`) serves as a monotonically increasing cursor.
### In-Memory Broker ### In-Memory Broker
The server maintains an in-memory notification broker to avoid database The server maintains an in-memory notification broker to avoid database
polling. The broker is a map of `user_id → []chan struct{}`. When a message polling. The broker is a map of `client_id → []chan struct{}`. When a message
is enqueued for a user: is enqueued for a client:
1. The handler calls `broker.Notify(userID)` 1. The handler calls `broker.Notify(clientID)`
2. The broker closes all waiting channels for that user 2. The broker closes all waiting channels for that client
3. Any goroutines blocked in `select` on those channels wake up 3. Any goroutines blocked in `select` on those channels wake up
4. The woken handler queries the database for new queue entries 4. The woken handler queries the database for new queue entries
5. Messages are returned to the client 5. Messages are returned to the client
@@ -523,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). | | `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.** | | `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. | | `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:** **Important invariants:**
@@ -1012,14 +1012,13 @@ carries IRC-style parameters (e.g., channel name, target nick).
Inspired by IRC, simplified: Inspired by IRC, simplified:
| Mode | Name | Meaning | | Mode | Name | Meaning |
|------|----------------|---------| |------|--------------|---------|
| `+i` | Invite-only | Only invited users can join | | `+i` | Invite-only | Only invited users can join |
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | | `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send |
| `+s` | Secret | Channel hidden from LIST response | | `+s` | Secret | Channel hidden from LIST response |
| `+t` | Topic lock | Only operators can change the topic | | `+t` | Topic lock | Only operators can change the topic |
| `+n` | No external | Only channel members can send messages to the channel | | `+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):** **User channel modes (set per-user per-channel):**
@@ -1030,56 +1029,6 @@ Inspired by IRC, simplified:
**Status:** Channel modes are defined but not yet enforced. The `modes` column **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. 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.
--- ---
@@ -1987,45 +1936,51 @@ The database schema is managed via embedded SQL migration files in
**Current tables:** **Current tables:**
#### `sessions` #### `sessions`
| Column | Type | Description | | Column | Type | Description |
|----------------|----------|-------------| |-----------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID | | `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick | | `nick` | TEXT | Unique nick |
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) | | `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key (empty string if unset) | | `signing_key` | TEXT | Public signing key (empty string if unset) |
| `away_message` | TEXT | Away message (empty string if not away) | | `away_message` | TEXT | Away message (empty string if not away) |
| `created_at` | DATETIME | Session creation time | | `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time | | `last_seen` | DATETIME | Last API request time |
Index on `(uuid)`.
#### `clients` #### `clients`
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |--------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique client UUID | | `uuid` | TEXT | Unique client UUID |
| `session_id`| INTEGER | FK → sessions.id (cascade delete) | | `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) | | `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) |
| `created_at`| DATETIME | Client creation time | | `created_at` | DATETIME | Client creation time |
| `last_seen` | DATETIME | Last API request time | | `last_seen` | DATETIME | Last API request time |
Indexes on `(token)` and `(session_id)`.
#### `channels` #### `channels`
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |---------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `name` | TEXT | Unique channel name (e.g., `#general`) | | `name` | TEXT | Unique channel name (e.g., `#general`) |
| `topic` | TEXT | Channel topic (default empty) | | `topic` | TEXT | Channel topic (default empty) |
| `created_at`| DATETIME | Channel creation time | | `topic_set_by`| TEXT | Nick of the user who set the topic (default empty) |
| `updated_at`| DATETIME | Last modification time | | `topic_set_at`| DATETIME | When the topic was last set |
| `created_at` | DATETIME | Channel creation time |
| `updated_at` | DATETIME | Last modification time |
#### `channel_members` #### `channel_members`
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |--------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) | | `id` | INTEGER | Primary key (auto-increment) |
| `channel_id`| INTEGER | FK → channels.id | | `channel_id` | INTEGER | FK → channels.id (cascade delete) |
| `user_id` | INTEGER | FK → users.id | | `session_id` | INTEGER | FK → sessions.id (cascade delete) |
| `joined_at` | DATETIME | When the user joined | | `joined_at` | DATETIME | When the user joined |
Unique constraint on `(channel_id, user_id)`. Unique constraint on `(channel_id, session_id)`.
#### `messages` #### `messages`
| Column | Type | Description | | Column | Type | Description |
@@ -2035,6 +1990,7 @@ Unique constraint on `(channel_id, user_id)`.
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | | `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
| `msg_from` | TEXT | Sender nick | | `msg_from` | TEXT | Sender nick |
| `msg_to` | TEXT | Target (`#channel` or nick) | | `msg_to` | TEXT | Target (`#channel` or nick) |
| `params` | TEXT | JSON-encoded IRC-style positional parameters |
| `body` | TEXT | JSON-encoded body (array or object) | | `body` | TEXT | JSON-encoded body (array or object) |
| `meta` | TEXT | JSON-encoded metadata | | `meta` | TEXT | JSON-encoded metadata |
| `created_at`| DATETIME | Server timestamp | | `created_at`| DATETIME | Server timestamp |
@@ -2045,11 +2001,11 @@ Indexes on `(msg_to, id)` and `(created_at)`.
| Column | Type | Description | | Column | Type | Description |
|-------------|----------|-------------| |-------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. | | `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. |
| `user_id` | INTEGER | FK → users.id | | `client_id` | INTEGER | FK → clients.id (cascade delete) |
| `message_id`| INTEGER | FK → messages.id | | `message_id`| INTEGER | FK → messages.id (cascade delete) |
| `created_at`| DATETIME | When the entry was queued | | `created_at`| DATETIME | When the entry was queued |
Unique constraint on `(user_id, message_id)`. Index on `(user_id, id)`. Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`.
The `client_queues.id` is the monotonically increasing cursor used by The `client_queues.id` is the monotonically increasing cursor used by
`GET /messages?after=<id>`. This is more reliable than timestamps (no clock `GET /messages?after=<id>`. This is more reliable than timestamps (no clock

View File

@@ -7,8 +7,6 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
) )
const ( 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 // hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits. // leading zero bits.
func hasLeadingZeroBits( func hasLeadingZeroBits(

View File

@@ -1305,110 +1305,3 @@ func (database *Database) GetQueueEntryCount(
return count, nil 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 TEXT NOT NULL DEFAULT '',
topic_set_by TEXT NOT NULL DEFAULT '', topic_set_by TEXT NOT NULL DEFAULT '',
topic_set_at DATETIME, topic_set_at DATETIME,
hashcash_bits INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_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_to_id ON messages(msg_to, id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at); 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 -- Per-client message queues for fan-out delivery
CREATE TABLE IF NOT EXISTS client_queues ( CREATE TABLE IF NOT EXISTS client_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -3,7 +3,6 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
@@ -12,16 +11,10 @@ import (
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/pkg/irc" "git.eeqj.de/sneak/neoirc/pkg/irc"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
var (
errHashcashRequired = errors.New("hashcash required")
errHashcashReused = errors.New("hashcash reused")
)
var validNickRe = regexp.MustCompile( var validNickRe = regexp.MustCompile(
`^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`, `^[a-zA-Z_][a-zA-Z0-9_\-\[\]\\^{}|` + "`" + `]{0,31}$`,
) )
@@ -95,11 +88,10 @@ func (hdlr *Handlers) fanOut(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) (string, error) { ) (string, error) {
dbID, msgUUID, err := hdlr.params.Database.InsertMessage( 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 { if err != nil {
return "", fmt.Errorf("insert message: %w", err) return "", fmt.Errorf("insert message: %w", err)
@@ -125,11 +117,10 @@ func (hdlr *Handlers) fanOutSilent(
request *http.Request, request *http.Request,
command, from, target string, command, from, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
sessionIDs []int64, sessionIDs []int64,
) error { ) error {
_, err := hdlr.fanOut( _, err := hdlr.fanOut(
request, command, from, target, body, meta, sessionIDs, request, command, from, target, body, sessionIDs,
) )
return err return err
@@ -303,7 +294,7 @@ func (hdlr *Handlers) deliverWelcome(
[]string{ []string{
"CHANTYPES=#", "CHANTYPES=#",
"NICKLEN=32", "NICKLEN=32",
"CHANMODES=,,H," + "imnst", "CHANMODES=,,," + "imnst",
"NETWORK=neoirc", "NETWORK=neoirc",
"CASEMAPPING=ascii", "CASEMAPPING=ascii",
}, },
@@ -834,7 +825,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc {
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
payload.Command, payload.To, payload.Command, payload.To,
payload.Body, payload.Meta, bodyLines, payload.Body, bodyLines,
) )
} }
} }
@@ -845,7 +836,6 @@ func (hdlr *Handlers) dispatchCommand(
sessionID, clientID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
switch command { switch command {
@@ -858,7 +848,7 @@ func (hdlr *Handlers) dispatchCommand(
hdlr.handlePrivmsg( hdlr.handlePrivmsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, bodyLines, command, target, body, bodyLines,
) )
case irc.CmdJoin: case irc.CmdJoin:
hdlr.handleJoin( hdlr.handleJoin(
@@ -959,7 +949,6 @@ func (hdlr *Handlers) handlePrivmsg(
sessionID, clientID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if target == "" { if target == "" {
@@ -997,7 +986,7 @@ func (hdlr *Handlers) handlePrivmsg(
hdlr.handleChannelMsg( hdlr.handleChannelMsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, command, target, body,
) )
return return
@@ -1006,7 +995,7 @@ func (hdlr *Handlers) handlePrivmsg(
hdlr.handleDirectMsg( hdlr.handleDirectMsg(
writer, request, writer, request,
sessionID, clientID, nick, sessionID, clientID, nick,
command, target, body, meta, command, target, body,
) )
} }
@@ -1037,7 +1026,6 @@ func (hdlr *Handlers) handleChannelMsg(
sessionID, clientID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
) { ) {
chID, err := hdlr.params.Database.GetChannelByName( chID, err := hdlr.params.Database.GetChannelByName(
request.Context(), target, request.Context(), target,
@@ -1078,180 +1066,16 @@ func (hdlr *Handlers) handleChannelMsg(
return return
} }
hashcashErr := hdlr.validateChannelHashcash(
request, clientID, sessionID,
writer, nick, target, body, meta, chID,
)
if hashcashErr != nil {
return
}
hdlr.sendChannelMsg( hdlr.sendChannelMsg(
writer, request, command, nick, target, writer, request, command, nick, target, body, chID,
body, meta, 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( func (hdlr *Handlers) sendChannelMsg(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
command, nick, target string, command, nick, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
chID int64, chID int64,
) { ) {
memberIDs, err := hdlr.params.Database.GetChannelMemberIDs( memberIDs, err := hdlr.params.Database.GetChannelMemberIDs(
@@ -1271,7 +1095,7 @@ func (hdlr *Handlers) sendChannelMsg(
} }
msgUUID, err := hdlr.fanOut( msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, meta, memberIDs, request, command, nick, target, body, memberIDs,
) )
if err != nil { if err != nil {
hdlr.log.Error("send message failed", "error", err) hdlr.log.Error("send message failed", "error", err)
@@ -1295,7 +1119,6 @@ func (hdlr *Handlers) handleDirectMsg(
sessionID, clientID int64, sessionID, clientID int64,
nick, command, target string, nick, command, target string,
body json.RawMessage, body json.RawMessage,
meta json.RawMessage,
) { ) {
targetSID, err := hdlr.params.Database.GetSessionByNick( targetSID, err := hdlr.params.Database.GetSessionByNick(
request.Context(), target, request.Context(), target,
@@ -1320,7 +1143,7 @@ func (hdlr *Handlers) handleDirectMsg(
} }
msgUUID, err := hdlr.fanOut( msgUUID, err := hdlr.fanOut(
request, command, nick, target, body, meta, recipients, request, command, nick, target, body, recipients,
) )
if err != nil { if err != nil {
hdlr.log.Error("send dm failed", "error", err) hdlr.log.Error("send dm failed", "error", err)
@@ -1431,7 +1254,7 @@ func (hdlr *Handlers) executeJoin(
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs, request, irc.CmdJoin, nick, channel, nil, memberIDs,
) )
hdlr.deliverJoinNumerics( hdlr.deliverJoinNumerics(
@@ -1601,7 +1424,7 @@ func (hdlr *Handlers) handlePart(
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdPart, nick, channel, body, nil, memberIDs, request, irc.CmdPart, nick, channel, body, memberIDs,
) )
err = hdlr.params.Database.PartChannel( err = hdlr.params.Database.PartChannel(
@@ -1881,7 +1704,7 @@ func (hdlr *Handlers) executeTopic(
) )
_ = hdlr.fanOutSilent( _ = hdlr.fanOutSilent(
request, irc.CmdTopic, nick, channel, body, nil, memberIDs, request, irc.CmdTopic, nick, channel, body, memberIDs,
) )
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
@@ -2044,10 +1867,11 @@ func (hdlr *Handlers) handleMode(
return return
} }
_ = bodyLines
hdlr.handleChannelMode( hdlr.handleChannelMode(
writer, request, writer, request,
sessionID, clientID, nick, channel, sessionID, clientID, nick, channel,
bodyLines,
) )
} }
@@ -2056,7 +1880,6 @@ func (hdlr *Handlers) handleChannelMode(
request *http.Request, request *http.Request,
sessionID, clientID int64, sessionID, clientID int64,
nick, channel string, nick, channel string,
bodyLines func() []string,
) { ) {
ctx := request.Context() ctx := request.Context()
@@ -2073,47 +1896,10 @@ func (hdlr *Handlers) handleChannelMode(
return 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 // 324 RPL_CHANNELMODEIS
hdlr.enqueueNumeric( hdlr.enqueueNumeric(
ctx, clientID, irc.RplChannelModeIs, nick, ctx, clientID, irc.RplChannelModeIs, nick,
[]string{channel, modeStr}, "", []string{channel, "+n"}, "",
) )
// 329 RPL_CREATIONTIME // 329 RPL_CREATIONTIME
@@ -2138,156 +1924,6 @@ func (hdlr *Handlers) queryChannelMode(
http.StatusOK) 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. // handleNames sends NAMES reply for a channel.
func (hdlr *Handlers) handleNames( func (hdlr *Handlers) handleNames(
writer http.ResponseWriter, writer http.ResponseWriter,

View File

@@ -22,7 +22,6 @@ import (
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/handlers" "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/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"git.eeqj.de/sneak/neoirc/internal/middleware" "git.eeqj.de/sneak/neoirc/internal/middleware"
@@ -2158,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

@@ -36,21 +36,15 @@ type Params struct {
const defaultIdleTimeout = 30 * 24 * time.Hour 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. // Handlers manages HTTP request handling.
type Handlers struct { type Handlers struct {
params *Params params *Params
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator hashcashVal *hashcash.Validator
channelHashcash *hashcash.ChannelValidator stats *stats.Tracker
stats *stats.Tracker cancelCleanup context.CancelFunc
cancelCleanup context.CancelFunc
} }
// New creates a new Handlers instance. // New creates a new Handlers instance.
@@ -64,13 +58,12 @@ func New(
} }
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
broker: broker.New(), broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource), hashcashVal: hashcash.NewValidator(resource),
channelHashcash: hashcash.NewChannelValidator(), stats: params.Stats,
stats: params.Stats,
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
@@ -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)
}
}