Compare commits
1 Commits
feature/ch
...
docs/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2af55e4a5 |
19
Makefile
19
Makefile
@@ -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
164
README.md
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: ¶ms,
|
params: ¶ms,
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user