feat: per-channel hashcash proof-of-work for PRIVMSG anti-spam
All checks were successful
check / check (push) Successful in 1m11s
All checks were successful
check / check (push) Successful in 1m11s
Add per-channel hashcash requirement via MODE +H <bits>. When set, PRIVMSG to the channel must include a valid hashcash stamp in the meta.hashcash field bound to the channel name and message body hash. Server validates stamp format, difficulty, date freshness, channel binding, body hash binding, and proof-of-work. Spent stamps are persisted to SQLite with 1-year TTL for replay prevention. Stamp format: 1:bits:YYMMDD:channel:bodyhash:counter Changes: - Schema: add hashcash_bits column to channels, spent_hashcash table - DB: queries for get/set channel hashcash bits, spent token CRUD - Hashcash: ChannelValidator, BodyHash, StampHash, MintChannelStamp - Handlers: validate hashcash on PRIVMSG, MODE +H/-H support - Pass meta through fanOut chain to store in messages - Prune spent hashcash tokens in cleanup loop (1-year TTL) - Client: MintChannelHashcash helper for CLI - Tests: 12 new channel_test.go + 10 new api_test.go integration tests - README: document +H mode, stamp format, and usage
This commit is contained in:
19
Makefile
19
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks
|
.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist
|
||||||
|
|
||||||
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,10 +7,21 @@ LDFLAGS := -X main.Version=$(VERSION) -X main.Buildarch=$(BUILDARCH)
|
|||||||
|
|
||||||
all: check build
|
all: check build
|
||||||
|
|
||||||
build:
|
# ensure-web-dist creates placeholder files so //go:embed dist/* in
|
||||||
|
# web/embed.go resolves without a full Node.js build. The real SPA is
|
||||||
|
# built by the web-builder Docker stage; these placeholders let
|
||||||
|
# "make test" and "make build" work outside Docker.
|
||||||
|
ensure-web-dist:
|
||||||
|
@if [ ! -d web/dist ]; then \
|
||||||
|
mkdir -p web/dist && \
|
||||||
|
touch web/dist/index.html web/dist/style.css web/dist/app.js && \
|
||||||
|
echo "==> Created placeholder web/dist/ for go:embed"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
build: ensure-web-dist
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd
|
||||||
|
|
||||||
lint:
|
lint: ensure-web-dist
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@@ -20,7 +31,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:
|
test: ensure-web-dist
|
||||||
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
|
||||||
|
|||||||
67
README.md
67
README.md
@@ -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`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim — it does not interpret or validate it. |
|
| `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. |
|
||||||
|
|
||||||
**Important invariants:**
|
**Important invariants:**
|
||||||
|
|
||||||
@@ -1012,13 +1012,14 @@ 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):**
|
||||||
|
|
||||||
@@ -1029,6 +1030,56 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -37,6 +39,23 @@ 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,3 +1305,110 @@ 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,6 +33,7 @@ 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
|
||||||
);
|
);
|
||||||
@@ -61,6 +62,14 @@ 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,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -11,10 +12,16 @@ 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}$`,
|
||||||
)
|
)
|
||||||
@@ -88,10 +95,11 @@ 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, nil,
|
request.Context(), command, from, target, nil, body, meta,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("insert message: %w", err)
|
return "", fmt.Errorf("insert message: %w", err)
|
||||||
@@ -117,10 +125,11 @@ 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, sessionIDs,
|
request, command, from, target, body, meta, sessionIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -294,7 +303,7 @@ func (hdlr *Handlers) deliverWelcome(
|
|||||||
[]string{
|
[]string{
|
||||||
"CHANTYPES=#",
|
"CHANTYPES=#",
|
||||||
"NICKLEN=32",
|
"NICKLEN=32",
|
||||||
"CHANMODES=,,," + "imnst",
|
"CHANMODES=,,H," + "imnst",
|
||||||
"NETWORK=neoirc",
|
"NETWORK=neoirc",
|
||||||
"CASEMAPPING=ascii",
|
"CASEMAPPING=ascii",
|
||||||
},
|
},
|
||||||
@@ -825,7 +834,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, bodyLines,
|
payload.Body, payload.Meta, bodyLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,6 +845,7 @@ 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 {
|
||||||
@@ -848,7 +858,7 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body, bodyLines,
|
command, target, body, meta, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdJoin:
|
case irc.CmdJoin:
|
||||||
hdlr.handleJoin(
|
hdlr.handleJoin(
|
||||||
@@ -949,6 +959,7 @@ 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 == "" {
|
||||||
@@ -986,7 +997,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
hdlr.handleChannelMsg(
|
hdlr.handleChannelMsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body,
|
command, target, body, meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -995,7 +1006,7 @@ func (hdlr *Handlers) handlePrivmsg(
|
|||||||
hdlr.handleDirectMsg(
|
hdlr.handleDirectMsg(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
command, target, body,
|
command, target, body, meta,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1026,6 +1037,7 @@ 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,
|
||||||
@@ -1066,9 +1078,172 @@ func (hdlr *Handlers) handleChannelMsg(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.sendChannelMsg(
|
hashcashErr := hdlr.validateChannelHashcash(
|
||||||
writer, request, command, nick, target, body, chID,
|
request, clientID, sessionID,
|
||||||
|
writer, nick, target, body, meta, chID,
|
||||||
)
|
)
|
||||||
|
if hashcashErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.sendChannelMsg(
|
||||||
|
writer, request, command, nick, target,
|
||||||
|
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(
|
||||||
@@ -1076,6 +1251,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
|||||||
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(
|
||||||
@@ -1095,7 +1271,7 @@ func (hdlr *Handlers) sendChannelMsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgUUID, err := hdlr.fanOut(
|
msgUUID, err := hdlr.fanOut(
|
||||||
request, command, nick, target, body, memberIDs,
|
request, command, nick, target, body, meta, memberIDs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error("send message failed", "error", err)
|
hdlr.log.Error("send message failed", "error", err)
|
||||||
@@ -1119,6 +1295,7 @@ 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,
|
||||||
@@ -1143,7 +1320,7 @@ func (hdlr *Handlers) handleDirectMsg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgUUID, err := hdlr.fanOut(
|
msgUUID, err := hdlr.fanOut(
|
||||||
request, command, nick, target, body, recipients,
|
request, command, nick, target, body, meta, recipients,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
hdlr.log.Error("send dm failed", "error", err)
|
hdlr.log.Error("send dm failed", "error", err)
|
||||||
@@ -1254,7 +1431,7 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdJoin, nick, channel, nil, memberIDs,
|
request, irc.CmdJoin, nick, channel, nil, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.deliverJoinNumerics(
|
hdlr.deliverJoinNumerics(
|
||||||
@@ -1424,7 +1601,7 @@ func (hdlr *Handlers) handlePart(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdPart, nick, channel, body, memberIDs,
|
request, irc.CmdPart, nick, channel, body, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
err = hdlr.params.Database.PartChannel(
|
err = hdlr.params.Database.PartChannel(
|
||||||
@@ -1704,7 +1881,7 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = hdlr.fanOutSilent(
|
_ = hdlr.fanOutSilent(
|
||||||
request, irc.CmdTopic, nick, channel, body, memberIDs,
|
request, irc.CmdTopic, nick, channel, body, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
@@ -1867,11 +2044,10 @@ func (hdlr *Handlers) handleMode(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = bodyLines
|
|
||||||
|
|
||||||
hdlr.handleChannelMode(
|
hdlr.handleChannelMode(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, channel,
|
sessionID, clientID, nick, channel,
|
||||||
|
bodyLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1880,6 +2056,7 @@ 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()
|
||||||
|
|
||||||
@@ -1896,10 +2073,47 @@ 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, "+n"}, "",
|
[]string{channel, modeStr}, "",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 329 RPL_CREATIONTIME
|
// 329 RPL_CREATIONTIME
|
||||||
@@ -1924,6 +2138,156 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
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,6 +22,7 @@ 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"
|
||||||
@@ -2157,3 +2158,397 @@ 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,15 +36,21 @@ 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
|
||||||
stats *stats.Tracker
|
channelHashcash *hashcash.ChannelValidator
|
||||||
cancelCleanup context.CancelFunc
|
stats *stats.Tracker
|
||||||
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
@@ -58,12 +64,13 @@ 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),
|
||||||
stats: params.Stats,
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -285,4 +292,20 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
186
internal/hashcash/channel.go
Normal file
186
internal/hashcash/channel.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
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++
|
||||||
|
}
|
||||||
|
}
|
||||||
244
internal/hashcash/channel_test.go
Normal file
244
internal/hashcash/channel_test.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
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