From 75cecd980387746af42de0f11638ebd6ef82ca04 Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 13 Mar 2026 00:38:41 +0100 Subject: [PATCH] feat: implement hashcash proof-of-work for session creation (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implement SHA-256-based hashcash proof-of-work for `POST /session` to prevent abuse via rapid session creation. closes https://git.eeqj.de/sneak/chat/issues/11 ## What Changed ### Server - **New `internal/hashcash` package**: Validates hashcash stamps (format, difficulty bits, date/expiry, resource, replay prevention via in-memory spent set with TTL pruning) - **Config**: `NEOIRC_HASHCASH_BITS` env var (default 20, set to 0 to disable) - **`GET /api/v1/server`**: Now includes `hashcash_bits` field when > 0 - **`POST /api/v1/session`**: Validates `X-Hashcash` header when hashcash is enabled; returns HTTP 402 for missing/invalid stamps ### Clients - **Web SPA**: Fetches `hashcash_bits` from `/server`, computes stamp using Web Crypto API (`crypto.subtle.digest`) with batched parallelism (1024 hashes/batch), shows "Computing proof-of-work..." feedback - **CLI (`neoirc-cli`)**: `CreateSession()` auto-fetches server info and computes a valid hashcash stamp when required; new `MintHashcash()` function in the API package ### Documentation - README updated with full hashcash documentation: stamp format, computing stamps, configuration, difficulty table - Server info and session creation API docs updated with hashcash fields/headers - Roadmap updated (hashcash marked as implemented) ## Stamp Format Standard hashcash: `1:bits:YYMMDD:resource::counter` The SHA-256 hash of the entire stamp string must have at least `bits` leading zero bits. ## Validation Rules - Version must be `1` - Claimed bits ≥ required bits - Resource must match server name - Date within 48 hours (not expired, not too far in future) - SHA-256 hash has required leading zero bits - Stamp not previously used (replay prevention) ## Testing - All existing tests pass (hashcash disabled in test config with `HashcashBits: 0`) - `docker build .` passes (lint + test + build) Co-authored-by: clawbot Co-authored-by: clawbot Co-authored-by: user Co-authored-by: Jeffrey Paul Reviewed-on: https://git.eeqj.de/sneak/chat/pulls/63 Co-authored-by: clawbot Co-committed-by: clawbot --- README.md | 206 ++-- cmd/neoirc-cli/main.go | 907 +---------------- .../neoirc-cli => internal/cli}/api/client.go | 19 +- internal/cli/api/hashcash.go | 79 ++ {cmd/neoirc-cli => internal/cli}/api/types.go | 10 +- internal/cli/app.go | 912 ++++++++++++++++++ {cmd/neoirc-cli => internal/cli}/ui.go | 2 +- internal/config/config.go | 3 + internal/handlers/api.go | 41 +- internal/handlers/api_test.go | 1 + internal/handlers/handlers.go | 16 +- internal/hashcash/hashcash.go | 277 ++++++ internal/hashcash/hashcash_test.go | 261 +++++ web/src/app.jsx | 69 +- 14 files changed, 1795 insertions(+), 1008 deletions(-) rename {cmd/neoirc-cli => internal/cli}/api/client.go (92%) create mode 100644 internal/cli/api/hashcash.go rename {cmd/neoirc-cli => internal/cli}/api/types.go (88%) create mode 100644 internal/cli/app.go rename {cmd/neoirc-cli => internal/cli}/ui.go (99%) create mode 100644 internal/hashcash/hashcash.go create mode 100644 internal/hashcash/hashcash_test.go diff --git a/README.md b/README.md index c9cb666..2f48e23 100644 --- a/README.md +++ b/README.md @@ -987,14 +987,20 @@ the format: Create a new user session. This is the entry point for all clients. -**Request:** +If the server requires hashcash proof-of-work (see +[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a +valid stamp in the `pow_token` field of the JSON request body. The required +difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. + +**Request Body:** ```json -{"nick": "alice"} +{"nick": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"} ``` -| Field | Type | Required | Constraints | -|--------|--------|----------|-------------| -| `nick` | string | Yes | 1–32 characters, must be unique on the server | +| Field | Type | Required | Constraints | +|------------|--------|-------------|-------------| +| `nick` | string | Yes | 1–32 characters, must be unique on the server | +| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | **Response:** `201 Created` ```json @@ -1016,13 +1022,15 @@ Create a new user session. This is the entry point for all clients. | Status | Error | When | |--------|-------|------| | 400 | `nick must be 1-32 characters` | Empty or too-long nick | +| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled | +| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | | 409 | `nick already taken` | Another active session holds this nick | **curl example:** ```bash TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ -H 'Content-Type: application/json' \ - -d '{"nick":"alice"}' | jq -r .token) + -d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' | jq -r .token) echo $TOKEN ``` @@ -1376,16 +1384,18 @@ Return server metadata. No authentication required. "name": "My NeoIRC Server", "version": "0.1.0", "motd": "Welcome! Be nice.", - "users": 42 + "users": 42, + "hashcash_bits": 20 } ``` -| Field | Type | Description | -|-----------|---------|-------------| -| `name` | string | Server display name | -| `version` | string | Server version | -| `motd` | string | Message of the day | -| `users` | integer | Number of currently active user sessions | +| Field | Type | Description | +|-----------------|---------|-------------| +| `name` | string | Server display name | +| `version` | string | Server version | +| `motd` | string | Message of the day | +| `users` | integer | Number of currently active user sessions | +| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). | ### GET /.well-known/healthcheck.json — Health Check @@ -1823,6 +1833,7 @@ directory is also loaded automatically via | `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | | `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | | `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | +| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | | `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file @@ -1834,6 +1845,7 @@ MOTD=Welcome! Be excellent to each other. DEBUG=false DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL SESSION_IDLE_TIMEOUT=720h +NEOIRC_HASHCASH_BITS=20 ``` --- @@ -2106,62 +2118,102 @@ Clients should handle these message commands from the queue: ## Rate Limiting & Abuse Prevention -Session creation (`POST /api/v1/session`) will require a +### Hashcash Proof-of-Work + +Session creation (`POST /api/v1/session`) requires a [hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token. This is the primary defense against resource exhaustion — no CAPTCHAs, no account registration, no IP-based rate limits that punish shared networks. ### How It Works -1. Client requests a challenge: `GET /api/v1/challenge` - ```json - → {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"} - ``` -2. Server returns a nonce and a required difficulty (number of leading zero - bits in the SHA-256 hash) -3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)` - has the required number of leading zero bits: - ``` - SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good) - SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good) - ... - SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!) - ``` -4. Client submits the proof with the session request: - ```json - POST /api/v1/session - {"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}} - ``` -5. Server verifies: - - Nonce was issued by this server and hasn't expired - - Nonce hasn't been used before (prevent replay) - - `SHA-256(nonce || ":" || counter)` has the required leading zeros - - If valid, create the session normally +1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits` + field (e.g., `20`) indicating the required difficulty. +2. Client computes a hashcash stamp: find a counter value such that the + SHA-256 hash of the stamp string has the required number of leading zero + bits. +3. Client includes the stamp in the `pow_token` field of the JSON request body when creating + a session: `POST /api/v1/session`. +4. Server validates the stamp: + - Version is `1` + - Claimed bits ≥ required bits + - Resource matches the server name + - Date is within 48 hours (not expired, not too far in the future) + - SHA-256 hash has the required leading zero bits + - Stamp has not been used before (replay prevention) -### Adaptive Difficulty +### Stamp Format -The required difficulty scales with server load. Under normal conditions, the -cost is negligible (a few milliseconds of CPU). As concurrent sessions or -session creation rate increases, difficulty rises — making bulk session creation -exponentially more expensive for attackers while remaining cheap for legitimate -single-user connections. +Standard hashcash format: -| Server Load | Difficulty (bits) | Approx. Client CPU | -|--------------------|-------------------|--------------------| -| Normal (< 100/min) | 16 | ~1ms | -| Elevated | 20 | ~15ms | -| High | 24 | ~250ms | -| Under attack | 28+ | ~4s+ | +``` +1:bits:date:resource::counter +``` -Each additional bit of difficulty doubles the expected work. An attacker -creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate -user creating one session needs ~4 seconds once and never again for the -duration of their session. +| Field | Description | +|------------|-------------| +| `1` | Version (always `1`) | +| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) | +| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) | +| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) | +| (empty) | Extension field (unused) | +| `counter` | Hex counter value found by the client to satisfy the PoW | + +**Example stamp:** `1:20:260310:neoirc::3a2f1b` + +The SHA-256 hash of this entire string must have at least 20 leading zero bits. + +### Computing a Stamp + +```bash +# Pseudocode +bits = 20 +resource = "neoirc" +date = "260310" # YYMMDD in UTC +counter = 0 + +loop: + stamp = "1:{bits}:{date}:{resource}::{hex(counter)}" + hash = SHA-256(stamp) + if leading_zero_bits(hash) >= bits: + return stamp + counter++ +``` + +At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on +average, taking roughly 0.5–2 seconds on modern hardware. + +### Client Integration + +Both the embedded web SPA and the CLI client automatically handle hashcash: + +1. Fetch `GET /api/v1/server` to read `hashcash_bits` +2. If `hashcash_bits > 0`, compute a valid stamp +3. Include the stamp in the `pow_token` field of the JSON body on `POST /api/v1/session` + +The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256 +computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. + +### Configuration + +Set `NEOIRC_HASHCASH_BITS` to control difficulty: + +| Value | Effect | Approx. Client CPU | +|-------|--------|---------------------| +| `0` | Disabled (no proof-of-work required) | — | +| `16` | Light protection | ~1ms | +| `20` | Default — good balance | ~0.5–2s | +| `24` | Strong protection | ~10–30s | +| `28` | Very strong (may frustrate users) | ~2–10min | + +Each additional bit doubles the expected work. An attacker creating 1000 +sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user +creating one session pays once and keeps their session. ### Why Hashcash and Not Rate Limits? - **No state to track**: No IP tables, no token buckets, no sliding windows. - The server only needs to verify a hash. + The server only needs to verify a single hash. - **Works through NATs and proxies**: Doesn't punish shared IPs (university campuses, corporate networks, Tor exits). Every client computes their own proof independently. @@ -2169,36 +2221,9 @@ duration of their session. (one SHA-256 hash) regardless of difficulty. Only the client does more work. - **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No registration, no email, no phone number, no CAPTCHA. Just compute. -- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU - once. A botnet trying to create thousands of sessions pays exponentially more. - **Language-agnostic**: SHA-256 is available in every programming language. The proof computation is trivially implementable in any client. -### Challenge Endpoint (Planned) - -``` -GET /api/v1/challenge -``` - -**Response:** `200 OK` -```json -{ - "nonce": "a1b2c3d4e5f6...", - "difficulty": 20, - "algorithm": "sha256", - "expires": "2026-02-10T20:01:00Z" -} -``` - -| Field | Type | Description | -|--------------|---------|-------------| -| `nonce` | string | Server-generated random hex string (32+ chars) | -| `difficulty` | integer | Required number of leading zero bits in the hash | -| `algorithm` | string | Hash algorithm (always `sha256` for now) | -| `expires` | string | ISO 8601 expiry time for this challenge | - -**Status:** Not yet implemented. Tracked for post-MVP. - --- ## Roadmap @@ -2227,7 +2252,7 @@ GET /api/v1/challenge ### Post-MVP (Planned) -- [ ] **Hashcash proof-of-work** for session creation (abuse prevention) +- [x] **Hashcash proof-of-work** for session creation (abuse prevention) - [x] **Client output queue pruning** — delete old client output queue entries per `QUEUE_MAX_AGE` - [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` @@ -2280,15 +2305,18 @@ neoirc/ ├── cmd/ │ ├── neoircd/ # Server binary entry point │ │ └── main.go -│ └── neoirc-cli/ # TUI client -│ ├── main.go # Command handling, poll loop -│ ├── ui.go # tview-based terminal UI -│ └── api/ -│ ├── client.go # HTTP API client library -│ └── types.go # Request/response types +│ └── neoirc-cli/ # TUI client entry point +│ └── main.go # Minimal bootstrapping (calls internal/cli) ├── internal/ │ ├── broker/ # In-memory pub/sub for long-poll notifications │ │ └── broker.go +│ ├── cli/ # TUI client implementation +│ │ ├── app.go # App struct, command handling, poll loop +│ │ ├── ui.go # tview-based terminal UI +│ │ └── api/ +│ │ ├── client.go # HTTP API client library +│ │ ├── types.go # Request/response types +│ │ └── hashcash.go # Hashcash proof-of-work minting │ ├── config/ # Viper-based configuration │ │ └── config.go │ ├── db/ # Database access and migrations diff --git a/cmd/neoirc-cli/main.go b/cmd/neoirc-cli/main.go index 28401d0..c10f0ba 100644 --- a/cmd/neoirc-cli/main.go +++ b/cmd/neoirc-cli/main.go @@ -1,911 +1,8 @@ // Package main is the entry point for the neoirc-cli client. package main -import ( - "fmt" - "os" - "strings" - "sync" - "time" - - api "git.eeqj.de/sneak/neoirc/cmd/neoirc-cli/api" - "git.eeqj.de/sneak/neoirc/pkg/irc" -) - -const ( - splitParts = 2 - pollTimeout = 15 - pollRetry = 2 * time.Second - timeFormat = "15:04" -) - -// App holds the application state. -type App struct { - ui *UI - client *api.Client - - mu sync.Mutex - nick string - target string - connected bool - lastQID int64 - stopPoll chan struct{} -} +import "git.eeqj.de/sneak/neoirc/internal/cli" func main() { - app := &App{ //nolint:exhaustruct - ui: NewUI(), - nick: "guest", - } - - app.ui.OnInput(app.handleInput) - app.ui.SetStatus(app.nick, "", "disconnected") - - app.ui.AddStatus( - "Welcome to neoirc-cli — an IRC-style client", - ) - app.ui.AddStatus( - "Type [yellow]/connect " + - "[white] to begin, " + - "or [yellow]/help[white] for commands", - ) - - err := app.ui.Run() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func (a *App) handleInput(text string) { - if strings.HasPrefix(text, "/") { - a.handleCommand(text) - - return - } - - a.mu.Lock() - target := a.target - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus( - "[red]Not connected. Use /connect ", - ) - - return - } - - if target == "" { - a.ui.AddStatus( - "[red]No target. " + - "Use /join #channel or /query nick", - ) - - return - } - - err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: irc.CmdPrivmsg, - To: target, - Body: []string{text}, - }) - if err != nil { - a.ui.AddStatus( - "[red]Send error: " + err.Error(), - ) - - return - } - - timestamp := time.Now().Format(timeFormat) - - a.mu.Lock() - nick := a.nick - a.mu.Unlock() - - a.ui.AddLine(target, fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - timestamp, nick, text, - )) -} - -func (a *App) handleCommand(text string) { - parts := strings.SplitN(text, " ", splitParts) - cmd := strings.ToLower(parts[0]) - - args := "" - if len(parts) > 1 { - args = parts[1] - } - - a.dispatchCommand(cmd, args) -} - -func (a *App) dispatchCommand(cmd, args string) { - switch cmd { - case "/connect": - a.cmdConnect(args) - case "/nick": - a.cmdNick(args) - case "/join": - a.cmdJoin(args) - case "/part": - a.cmdPart(args) - case "/msg": - a.cmdMsg(args) - case "/query": - a.cmdQuery(args) - case "/topic": - a.cmdTopic(args) - case "/window", "/w": - a.cmdWindow(args) - case "/quit": - a.cmdQuit() - case "/help": - a.cmdHelp() - default: - a.dispatchInfoCommand(cmd, args) - } -} - -func (a *App) dispatchInfoCommand(cmd, args string) { - switch cmd { - case "/names": - a.cmdNames() - case "/list": - a.cmdList() - case "/motd": - a.cmdMotd() - case "/who": - a.cmdWho(args) - case "/whois": - a.cmdWhois(args) - default: - a.ui.AddStatus( - "[red]Unknown command: " + cmd, - ) - } -} - -func (a *App) cmdConnect(serverURL string) { - if serverURL == "" { - a.ui.AddStatus( - "[red]Usage: /connect ", - ) - - return - } - - serverURL = strings.TrimRight(serverURL, "/") - - a.ui.AddStatus("Connecting to " + serverURL + "...") - - a.mu.Lock() - nick := a.nick - a.mu.Unlock() - - client := api.NewClient(serverURL) - - resp, err := client.CreateSession(nick) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Connection failed: %v", err, - )) - - return - } - - a.mu.Lock() - a.client = client - a.nick = resp.Nick - a.connected = true - a.lastQID = 0 - a.mu.Unlock() - - a.ui.AddStatus(fmt.Sprintf( - "[green]Connected! Nick: %s, Session: %d", - resp.Nick, resp.ID, - )) - a.ui.SetStatus(resp.Nick, "", "connected") - - a.stopPoll = make(chan struct{}) - - go a.pollLoop() -} - -func (a *App) cmdNick(nick string) { - if nick == "" { - a.ui.AddStatus( - "[red]Usage: /nick ", - ) - - return - } - - a.mu.Lock() - connected := a.connected - a.mu.Unlock() - - if !connected { - a.mu.Lock() - a.nick = nick - a.mu.Unlock() - - a.ui.AddStatus( - "Nick set to " + nick + - " (will be used on connect)", - ) - - return - } - - err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: irc.CmdNick, - Body: []string{nick}, - }) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Nick change failed: %v", err, - )) - - return - } - - a.mu.Lock() - a.nick = nick - target := a.target - a.mu.Unlock() - - a.ui.SetStatus(nick, target, "connected") - a.ui.AddStatus("Nick changed to " + nick) -} - -func (a *App) cmdJoin(channel string) { - if channel == "" { - a.ui.AddStatus( - "[red]Usage: /join #channel", - ) - - return - } - - if !strings.HasPrefix(channel, "#") { - channel = "#" + channel - } - - a.mu.Lock() - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - err := a.client.JoinChannel(channel) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Join failed: %v", err, - )) - - return - } - - a.mu.Lock() - a.target = channel - nick := a.nick - a.mu.Unlock() - - a.ui.SwitchToBuffer(channel) - a.ui.AddLine(channel, - "[yellow]*** Joined "+channel, - ) - a.ui.SetStatus(nick, channel, "connected") -} - -func (a *App) cmdPart(channel string) { - a.mu.Lock() - if channel == "" { - channel = a.target - } - - connected := a.connected - a.mu.Unlock() - - if channel == "" || - !strings.HasPrefix(channel, "#") { - a.ui.AddStatus("[red]No channel to part") - - return - } - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - err := a.client.PartChannel(channel) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Part failed: %v", err, - )) - - return - } - - a.ui.AddLine(channel, - "[yellow]*** Left "+channel, - ) - - a.mu.Lock() - if a.target == channel { - a.target = "" - } - - nick := a.nick - a.mu.Unlock() - - a.ui.SwitchBuffer(0) - a.ui.SetStatus(nick, "", "connected") -} - -func (a *App) cmdMsg(args string) { - parts := strings.SplitN(args, " ", splitParts) - if len(parts) < splitParts { - a.ui.AddStatus( - "[red]Usage: /msg ", - ) - - return - } - - target, text := parts[0], parts[1] - - a.mu.Lock() - connected := a.connected - nick := a.nick - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: irc.CmdPrivmsg, - To: target, - Body: []string{text}, - }) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Send failed: %v", err, - )) - - return - } - - timestamp := time.Now().Format(timeFormat) - - a.ui.AddLine(target, fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - timestamp, nick, text, - )) -} - -func (a *App) cmdQuery(nick string) { - if nick == "" { - a.ui.AddStatus( - "[red]Usage: /query ", - ) - - return - } - - a.mu.Lock() - a.target = nick - myNick := a.nick - a.mu.Unlock() - - a.ui.SwitchToBuffer(nick) - a.ui.SetStatus(myNick, nick, "connected") -} - -func (a *App) cmdTopic(args string) { - a.mu.Lock() - target := a.target - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - if !strings.HasPrefix(target, "#") { - a.ui.AddStatus("[red]Not in a channel") - - return - } - - if args == "" { - err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: irc.CmdTopic, - To: target, - }) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Topic query failed: %v", err, - )) - } - - return - } - - err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct - Command: irc.CmdTopic, - To: target, - Body: []string{args}, - }) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Topic set failed: %v", err, - )) - } -} - -func (a *App) cmdNames() { - a.mu.Lock() - target := a.target - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - if !strings.HasPrefix(target, "#") { - a.ui.AddStatus("[red]Not in a channel") - - return - } - - members, err := a.client.GetMembers(target) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Names failed: %v", err, - )) - - return - } - - a.ui.AddLine(target, fmt.Sprintf( - "[cyan]*** Members of %s: %s", - target, strings.Join(members, " "), - )) -} - -func (a *App) cmdList() { - a.mu.Lock() - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - channels, err := a.client.ListChannels() - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]List failed: %v", err, - )) - - return - } - - a.ui.AddStatus("[cyan]*** Channel list:") - - for _, ch := range channels { - a.ui.AddStatus(fmt.Sprintf( - " %s (%d members) %s", - ch.Name, ch.Members, ch.Topic, - )) - } - - a.ui.AddStatus("[cyan]*** End of channel list") -} - -func (a *App) cmdMotd() { - a.mu.Lock() - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - err := a.client.SendMessage( - &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct - ) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]MOTD failed: %v", err, - )) - } -} - -func (a *App) cmdWho(args string) { - a.mu.Lock() - connected := a.connected - target := a.target - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - channel := args - if channel == "" { - channel = target - } - - if channel == "" || - !strings.HasPrefix(channel, "#") { - a.ui.AddStatus( - "[red]Usage: /who #channel", - ) - - return - } - - err := a.client.SendMessage( - &api.Message{ //nolint:exhaustruct - Command: irc.CmdWho, To: channel, - }, - ) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]WHO failed: %v", err, - )) - } -} - -func (a *App) cmdWhois(args string) { - a.mu.Lock() - connected := a.connected - a.mu.Unlock() - - if !connected { - a.ui.AddStatus("[red]Not connected") - - return - } - - if args == "" { - a.ui.AddStatus( - "[red]Usage: /whois ", - ) - - return - } - - err := a.client.SendMessage( - &api.Message{ //nolint:exhaustruct - Command: irc.CmdWhois, To: args, - }, - ) - if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]WHOIS failed: %v", err, - )) - } -} - -func (a *App) cmdWindow(args string) { - if args == "" { - a.ui.AddStatus( - "[red]Usage: /window ", - ) - - return - } - - var bufIndex int - - _, _ = fmt.Sscanf(args, "%d", &bufIndex) - - a.ui.SwitchBuffer(bufIndex) - - a.mu.Lock() - nick := a.nick - a.mu.Unlock() - - if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { - buf := a.ui.buffers[bufIndex] - if buf.Name != "(status)" { - a.mu.Lock() - a.target = buf.Name - a.mu.Unlock() - - a.ui.SetStatus( - nick, buf.Name, "connected", - ) - } else { - a.ui.SetStatus(nick, "", "connected") - } - } -} - -func (a *App) cmdQuit() { - a.mu.Lock() - - if a.connected && a.client != nil { - _ = a.client.SendMessage( - &api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct - ) - } - - if a.stopPoll != nil { - close(a.stopPoll) - } - - a.mu.Unlock() - a.ui.Stop() -} - -func (a *App) cmdHelp() { - help := []string{ - "[cyan]*** neoirc-cli commands:", - " /connect — Connect to server", - " /nick — Change nickname", - " /join #channel — Join channel", - " /part [#chan] — Leave channel", - " /msg — Send DM", - " /query — Open DM window", - " /topic [text] — View/set topic", - " /names — List channel members", - " /list — List channels", - " /who [#channel] — List users in channel", - " /whois — Show user info", - " /motd — Show message of the day", - " /window — Switch buffer", - " /quit — Disconnect and exit", - " /help — This help", - " Plain text sends to current target.", - } - - for _, line := range help { - a.ui.AddStatus(line) - } -} - -// pollLoop long-polls for messages in the background. -func (a *App) pollLoop() { - for { - select { - case <-a.stopPoll: - return - default: - } - - a.mu.Lock() - client := a.client - lastQID := a.lastQID - a.mu.Unlock() - - if client == nil { - return - } - - result, err := client.PollMessages( - lastQID, pollTimeout, - ) - if err != nil { - time.Sleep(pollRetry) - - continue - } - - if result.LastID > 0 { - a.mu.Lock() - a.lastQID = result.LastID - a.mu.Unlock() - } - - for i := range result.Messages { - a.handleServerMessage(&result.Messages[i]) - } - } -} - -func (a *App) handleServerMessage(msg *api.Message) { - timestamp := a.formatTS(msg) - - a.mu.Lock() - myNick := a.nick - a.mu.Unlock() - - switch msg.Command { - case irc.CmdPrivmsg: - a.handlePrivmsgEvent(msg, timestamp, myNick) - case irc.CmdJoin: - a.handleJoinEvent(msg, timestamp) - case irc.CmdPart: - a.handlePartEvent(msg, timestamp) - case irc.CmdQuit: - a.handleQuitEvent(msg, timestamp) - case irc.CmdNick: - a.handleNickEvent(msg, timestamp, myNick) - case irc.CmdNotice: - a.handleNoticeEvent(msg, timestamp) - case irc.CmdTopic: - a.handleTopicEvent(msg, timestamp) - default: - a.handleDefaultEvent(msg, timestamp) - } -} - -func (a *App) formatTS(msg *api.Message) string { - if msg.TS != "" { - return msg.ParseTS().UTC().Format(timeFormat) - } - - return time.Now().Format(timeFormat) -} - -func (a *App) handlePrivmsgEvent( - msg *api.Message, timestamp, myNick string, -) { - lines := msg.BodyLines() - text := strings.Join(lines, " ") - - if msg.From == myNick { - return - } - - target := msg.To - if !strings.HasPrefix(target, "#") { - target = msg.From - } - - a.ui.AddLine(target, fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - timestamp, msg.From, text, - )) -} - -func (a *App) handleJoinEvent( - msg *api.Message, timestamp string, -) { - if msg.To == "" { - return - } - - a.ui.AddLine(msg.To, fmt.Sprintf( - "[gray]%s [yellow]*** %s has joined %s", - timestamp, msg.From, msg.To, - )) -} - -func (a *App) handlePartEvent( - msg *api.Message, timestamp string, -) { - if msg.To == "" { - return - } - - lines := msg.BodyLines() - reason := strings.Join(lines, " ") - - if reason != "" { - a.ui.AddLine(msg.To, fmt.Sprintf( - "[gray]%s [yellow]*** %s has left %s (%s)", - timestamp, msg.From, msg.To, reason, - )) - } else { - a.ui.AddLine(msg.To, fmt.Sprintf( - "[gray]%s [yellow]*** %s has left %s", - timestamp, msg.From, msg.To, - )) - } -} - -func (a *App) handleQuitEvent( - msg *api.Message, timestamp string, -) { - lines := msg.BodyLines() - reason := strings.Join(lines, " ") - - if reason != "" { - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [yellow]*** %s has quit (%s)", - timestamp, msg.From, reason, - )) - } else { - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [yellow]*** %s has quit", - timestamp, msg.From, - )) - } -} - -func (a *App) handleNickEvent( - msg *api.Message, timestamp, myNick string, -) { - lines := msg.BodyLines() - - newNick := "" - if len(lines) > 0 { - newNick = lines[0] - } - - if msg.From == myNick && newNick != "" { - a.mu.Lock() - a.nick = newNick - - target := a.target - a.mu.Unlock() - - a.ui.SetStatus(newNick, target, "connected") - } - - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [yellow]*** %s is now known as %s", - timestamp, msg.From, newNick, - )) -} - -func (a *App) handleNoticeEvent( - msg *api.Message, timestamp string, -) { - lines := msg.BodyLines() - text := strings.Join(lines, " ") - - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [magenta]--%s-- %s", - timestamp, msg.From, text, - )) -} - -func (a *App) handleTopicEvent( - msg *api.Message, timestamp string, -) { - if msg.To == "" { - return - } - - lines := msg.BodyLines() - text := strings.Join(lines, " ") - - a.ui.AddLine(msg.To, fmt.Sprintf( - "[gray]%s [cyan]*** %s set topic: %s", - timestamp, msg.From, text, - )) -} - -func (a *App) handleDefaultEvent( - msg *api.Message, timestamp string, -) { - lines := msg.BodyLines() - text := strings.Join(lines, " ") - - if text != "" { - a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [white][%s] %s", - timestamp, msg.Command, text, - )) - } + cli.Run() } diff --git a/cmd/neoirc-cli/api/client.go b/internal/cli/api/client.go similarity index 92% rename from cmd/neoirc-cli/api/client.go rename to internal/cli/api/client.go index ce94518..74561ab 100644 --- a/cmd/neoirc-cli/api/client.go +++ b/internal/cli/api/client.go @@ -43,13 +43,30 @@ func NewClient(baseURL string) *Client { } // CreateSession creates a new session on the server. +// If the server requires hashcash proof-of-work, it +// automatically fetches the difficulty and computes a +// valid stamp. func (client *Client) CreateSession( nick string, ) (*SessionResponse, error) { + // Fetch server info to check for hashcash requirement. + info, err := client.GetServerInfo() + + var hashcashStamp string + + if err == nil && info.HashcashBits > 0 { + resource := info.Name + if resource == "" { + resource = "neoirc" + } + + hashcashStamp = MintHashcash(info.HashcashBits, resource) + } + data, err := client.do( http.MethodPost, "/api/v1/session", - &SessionRequest{Nick: nick}, + &SessionRequest{Nick: nick, Hashcash: hashcashStamp}, ) if err != nil { return nil, err diff --git a/internal/cli/api/hashcash.go b/internal/cli/api/hashcash.go new file mode 100644 index 0000000..c064007 --- /dev/null +++ b/internal/cli/api/hashcash.go @@ -0,0 +1,79 @@ +package neoircapi + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "time" +) + +const ( + // bitsPerByte is the number of bits in a byte. + bitsPerByte = 8 + // fullByteMask is 0xFF, a mask for all bits in a byte. + fullByteMask = 0xFF + // counterSpace is the range for random counter seeds. + counterSpace = 1 << 48 +) + +// MintHashcash computes a hashcash stamp with the given +// difficulty (leading zero bits) and resource string. +func MintHashcash(bits int, resource string) string { + date := time.Now().UTC().Format("060102") + prefix := fmt.Sprintf( + "1:%d:%s:%s::", bits, date, resource, + ) + + for { + counter := randomCounter() + stamp := prefix + counter + hash := sha256.Sum256([]byte(stamp)) + + if hasLeadingZeroBits(hash[:], bits) { + return stamp + } + } +} + +// hasLeadingZeroBits checks if hash has at least numBits +// leading zero bits. +func hasLeadingZeroBits( + hash []byte, + numBits int, +) bool { + fullBytes := numBits / bitsPerByte + remainBits := numBits % bitsPerByte + + for idx := range fullBytes { + if hash[idx] != 0 { + return false + } + } + + if remainBits > 0 && fullBytes < len(hash) { + mask := byte( + fullByteMask << (bitsPerByte - remainBits), + ) + + if hash[fullBytes]&mask != 0 { + return false + } + } + + return true +} + +// randomCounter generates a random hex counter string. +func randomCounter() string { + counterVal, err := rand.Int( + rand.Reader, big.NewInt(counterSpace), + ) + if err != nil { + // Fallback to timestamp-based counter on error. + return fmt.Sprintf("%x", time.Now().UnixNano()) + } + + return hex.EncodeToString(counterVal.Bytes()) +} diff --git a/cmd/neoirc-cli/api/types.go b/internal/cli/api/types.go similarity index 88% rename from cmd/neoirc-cli/api/types.go rename to internal/cli/api/types.go index 96a0dc6..b9ae32e 100644 --- a/cmd/neoirc-cli/api/types.go +++ b/internal/cli/api/types.go @@ -4,7 +4,8 @@ import "time" // SessionRequest is the body for POST /api/v1/session. type SessionRequest struct { - Nick string `json:"nick"` + Nick string `json:"nick"` + Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle } // SessionResponse is the response from session creation. @@ -63,9 +64,10 @@ type Channel struct { // ServerInfo is the response from GET /api/v1/server. type ServerInfo struct { - Name string `json:"name"` - MOTD string `json:"motd"` - Version string `json:"version"` + Name string `json:"name"` + MOTD string `json:"motd"` + Version string `json:"version"` + HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle } // MessagesResponse wraps polling results. diff --git a/internal/cli/app.go b/internal/cli/app.go new file mode 100644 index 0000000..54adc06 --- /dev/null +++ b/internal/cli/app.go @@ -0,0 +1,912 @@ +// Package cli implements the neoirc-cli terminal client. +package cli + +import ( + "fmt" + "os" + "strings" + "sync" + "time" + + api "git.eeqj.de/sneak/neoirc/internal/cli/api" + "git.eeqj.de/sneak/neoirc/pkg/irc" +) + +const ( + splitParts = 2 + pollTimeout = 15 + pollRetry = 2 * time.Second + timeFormat = "15:04" +) + +// App holds the application state. +type App struct { + ui *UI + client *api.Client + + mu sync.Mutex + nick string + target string + connected bool + lastQID int64 + stopPoll chan struct{} +} + +// Run creates and runs the CLI application. +func Run() { + app := &App{ //nolint:exhaustruct + ui: NewUI(), + nick: "guest", + } + + app.ui.OnInput(app.handleInput) + app.ui.SetStatus(app.nick, "", "disconnected") + + app.ui.AddStatus( + "Welcome to neoirc-cli — an IRC-style client", + ) + app.ui.AddStatus( + "Type [yellow]/connect " + + "[white] to begin, " + + "or [yellow]/help[white] for commands", + ) + + err := app.ui.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func (a *App) handleInput(text string) { + if strings.HasPrefix(text, "/") { + a.handleCommand(text) + + return + } + + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus( + "[red]Not connected. Use /connect ", + ) + + return + } + + if target == "" { + a.ui.AddStatus( + "[red]No target. " + + "Use /join #channel or /query nick", + ) + + return + } + + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct + Command: irc.CmdPrivmsg, + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus( + "[red]Send error: " + err.Error(), + ) + + return + } + + timestamp := time.Now().Format(timeFormat) + + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + + a.ui.AddLine(target, fmt.Sprintf( + "[gray]%s [green]<%s>[white] %s", + timestamp, nick, text, + )) +} + +func (a *App) handleCommand(text string) { + parts := strings.SplitN(text, " ", splitParts) + cmd := strings.ToLower(parts[0]) + + args := "" + if len(parts) > 1 { + args = parts[1] + } + + a.dispatchCommand(cmd, args) +} + +func (a *App) dispatchCommand(cmd, args string) { + switch cmd { + case "/connect": + a.cmdConnect(args) + case "/nick": + a.cmdNick(args) + case "/join": + a.cmdJoin(args) + case "/part": + a.cmdPart(args) + case "/msg": + a.cmdMsg(args) + case "/query": + a.cmdQuery(args) + case "/topic": + a.cmdTopic(args) + case "/window", "/w": + a.cmdWindow(args) + case "/quit": + a.cmdQuit() + case "/help": + a.cmdHelp() + default: + a.dispatchInfoCommand(cmd, args) + } +} + +func (a *App) dispatchInfoCommand(cmd, args string) { + switch cmd { + case "/names": + a.cmdNames() + case "/list": + a.cmdList() + case "/motd": + a.cmdMotd() + case "/who": + a.cmdWho(args) + case "/whois": + a.cmdWhois(args) + default: + a.ui.AddStatus( + "[red]Unknown command: " + cmd, + ) + } +} + +func (a *App) cmdConnect(serverURL string) { + if serverURL == "" { + a.ui.AddStatus( + "[red]Usage: /connect ", + ) + + return + } + + serverURL = strings.TrimRight(serverURL, "/") + + a.ui.AddStatus("Connecting to " + serverURL + "...") + + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + + client := api.NewClient(serverURL) + + resp, err := client.CreateSession(nick) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Connection failed: %v", err, + )) + + return + } + + a.mu.Lock() + a.client = client + a.nick = resp.Nick + a.connected = true + a.lastQID = 0 + a.mu.Unlock() + + a.ui.AddStatus(fmt.Sprintf( + "[green]Connected! Nick: %s, Session: %d", + resp.Nick, resp.ID, + )) + a.ui.SetStatus(resp.Nick, "", "connected") + + a.stopPoll = make(chan struct{}) + + go a.pollLoop() +} + +func (a *App) cmdNick(nick string) { + if nick == "" { + a.ui.AddStatus( + "[red]Usage: /nick ", + ) + + return + } + + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.mu.Lock() + a.nick = nick + a.mu.Unlock() + + a.ui.AddStatus( + "Nick set to " + nick + + " (will be used on connect)", + ) + + return + } + + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct + Command: irc.CmdNick, + Body: []string{nick}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Nick change failed: %v", err, + )) + + return + } + + a.mu.Lock() + a.nick = nick + target := a.target + a.mu.Unlock() + + a.ui.SetStatus(nick, target, "connected") + a.ui.AddStatus("Nick changed to " + nick) +} + +func (a *App) cmdJoin(channel string) { + if channel == "" { + a.ui.AddStatus( + "[red]Usage: /join #channel", + ) + + return + } + + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + err := a.client.JoinChannel(channel) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Join failed: %v", err, + )) + + return + } + + a.mu.Lock() + a.target = channel + nick := a.nick + a.mu.Unlock() + + a.ui.SwitchToBuffer(channel) + a.ui.AddLine(channel, + "[yellow]*** Joined "+channel, + ) + a.ui.SetStatus(nick, channel, "connected") +} + +func (a *App) cmdPart(channel string) { + a.mu.Lock() + if channel == "" { + channel = a.target + } + + connected := a.connected + a.mu.Unlock() + + if channel == "" || + !strings.HasPrefix(channel, "#") { + a.ui.AddStatus("[red]No channel to part") + + return + } + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + err := a.client.PartChannel(channel) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Part failed: %v", err, + )) + + return + } + + a.ui.AddLine(channel, + "[yellow]*** Left "+channel, + ) + + a.mu.Lock() + if a.target == channel { + a.target = "" + } + + nick := a.nick + a.mu.Unlock() + + a.ui.SwitchBuffer(0) + a.ui.SetStatus(nick, "", "connected") +} + +func (a *App) cmdMsg(args string) { + parts := strings.SplitN(args, " ", splitParts) + if len(parts) < splitParts { + a.ui.AddStatus( + "[red]Usage: /msg ", + ) + + return + } + + target, text := parts[0], parts[1] + + a.mu.Lock() + connected := a.connected + nick := a.nick + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct + Command: irc.CmdPrivmsg, + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Send failed: %v", err, + )) + + return + } + + timestamp := time.Now().Format(timeFormat) + + a.ui.AddLine(target, fmt.Sprintf( + "[gray]%s [green]<%s>[white] %s", + timestamp, nick, text, + )) +} + +func (a *App) cmdQuery(nick string) { + if nick == "" { + a.ui.AddStatus( + "[red]Usage: /query ", + ) + + return + } + + a.mu.Lock() + a.target = nick + myNick := a.nick + a.mu.Unlock() + + a.ui.SwitchToBuffer(nick) + a.ui.SetStatus(myNick, nick, "connected") +} + +func (a *App) cmdTopic(args string) { + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + if !strings.HasPrefix(target, "#") { + a.ui.AddStatus("[red]Not in a channel") + + return + } + + if args == "" { + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct + Command: irc.CmdTopic, + To: target, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Topic query failed: %v", err, + )) + } + + return + } + + err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct + Command: irc.CmdTopic, + To: target, + Body: []string{args}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Topic set failed: %v", err, + )) + } +} + +func (a *App) cmdNames() { + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + if !strings.HasPrefix(target, "#") { + a.ui.AddStatus("[red]Not in a channel") + + return + } + + members, err := a.client.GetMembers(target) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]Names failed: %v", err, + )) + + return + } + + a.ui.AddLine(target, fmt.Sprintf( + "[cyan]*** Members of %s: %s", + target, strings.Join(members, " "), + )) +} + +func (a *App) cmdList() { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + channels, err := a.client.ListChannels() + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]List failed: %v", err, + )) + + return + } + + a.ui.AddStatus("[cyan]*** Channel list:") + + for _, ch := range channels { + a.ui.AddStatus(fmt.Sprintf( + " %s (%d members) %s", + ch.Name, ch.Members, ch.Topic, + )) + } + + a.ui.AddStatus("[cyan]*** End of channel list") +} + +func (a *App) cmdMotd() { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + err := a.client.SendMessage( + &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]MOTD failed: %v", err, + )) + } +} + +func (a *App) cmdWho(args string) { + a.mu.Lock() + connected := a.connected + target := a.target + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + channel := args + if channel == "" { + channel = target + } + + if channel == "" || + !strings.HasPrefix(channel, "#") { + a.ui.AddStatus( + "[red]Usage: /who #channel", + ) + + return + } + + err := a.client.SendMessage( + &api.Message{ //nolint:exhaustruct + Command: irc.CmdWho, To: channel, + }, + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]WHO failed: %v", err, + )) + } +} + +func (a *App) cmdWhois(args string) { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + + return + } + + if args == "" { + a.ui.AddStatus( + "[red]Usage: /whois ", + ) + + return + } + + err := a.client.SendMessage( + &api.Message{ //nolint:exhaustruct + Command: irc.CmdWhois, To: args, + }, + ) + if err != nil { + a.ui.AddStatus(fmt.Sprintf( + "[red]WHOIS failed: %v", err, + )) + } +} + +func (a *App) cmdWindow(args string) { + if args == "" { + a.ui.AddStatus( + "[red]Usage: /window ", + ) + + return + } + + var bufIndex int + + _, _ = fmt.Sscanf(args, "%d", &bufIndex) + + a.ui.SwitchBuffer(bufIndex) + + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + + if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { + buf := a.ui.buffers[bufIndex] + if buf.Name != "(status)" { + a.mu.Lock() + a.target = buf.Name + a.mu.Unlock() + + a.ui.SetStatus( + nick, buf.Name, "connected", + ) + } else { + a.ui.SetStatus(nick, "", "connected") + } + } +} + +func (a *App) cmdQuit() { + a.mu.Lock() + + if a.connected && a.client != nil { + _ = a.client.SendMessage( + &api.Message{Command: irc.CmdQuit}, //nolint:exhaustruct + ) + } + + if a.stopPoll != nil { + close(a.stopPoll) + } + + a.mu.Unlock() + a.ui.Stop() +} + +func (a *App) cmdHelp() { + help := []string{ + "[cyan]*** neoirc-cli commands:", + " /connect — Connect to server", + " /nick — Change nickname", + " /join #channel — Join channel", + " /part [#chan] — Leave channel", + " /msg — Send DM", + " /query — Open DM window", + " /topic [text] — View/set topic", + " /names — List channel members", + " /list — List channels", + " /who [#channel] — List users in channel", + " /whois — Show user info", + " /motd — Show message of the day", + " /window — Switch buffer", + " /quit — Disconnect and exit", + " /help — This help", + " Plain text sends to current target.", + } + + for _, line := range help { + a.ui.AddStatus(line) + } +} + +// pollLoop long-polls for messages in the background. +func (a *App) pollLoop() { + for { + select { + case <-a.stopPoll: + return + default: + } + + a.mu.Lock() + client := a.client + lastQID := a.lastQID + a.mu.Unlock() + + if client == nil { + return + } + + result, err := client.PollMessages( + lastQID, pollTimeout, + ) + if err != nil { + time.Sleep(pollRetry) + + continue + } + + if result.LastID > 0 { + a.mu.Lock() + a.lastQID = result.LastID + a.mu.Unlock() + } + + for i := range result.Messages { + a.handleServerMessage(&result.Messages[i]) + } + } +} + +func (a *App) handleServerMessage(msg *api.Message) { + timestamp := a.formatTS(msg) + + a.mu.Lock() + myNick := a.nick + a.mu.Unlock() + + switch msg.Command { + case irc.CmdPrivmsg: + a.handlePrivmsgEvent(msg, timestamp, myNick) + case irc.CmdJoin: + a.handleJoinEvent(msg, timestamp) + case irc.CmdPart: + a.handlePartEvent(msg, timestamp) + case irc.CmdQuit: + a.handleQuitEvent(msg, timestamp) + case irc.CmdNick: + a.handleNickEvent(msg, timestamp, myNick) + case irc.CmdNotice: + a.handleNoticeEvent(msg, timestamp) + case irc.CmdTopic: + a.handleTopicEvent(msg, timestamp) + default: + a.handleDefaultEvent(msg, timestamp) + } +} + +func (a *App) formatTS(msg *api.Message) string { + if msg.TS != "" { + return msg.ParseTS().UTC().Format(timeFormat) + } + + return time.Now().Format(timeFormat) +} + +func (a *App) handlePrivmsgEvent( + msg *api.Message, timestamp, myNick string, +) { + lines := msg.BodyLines() + text := strings.Join(lines, " ") + + if msg.From == myNick { + return + } + + target := msg.To + if !strings.HasPrefix(target, "#") { + target = msg.From + } + + a.ui.AddLine(target, fmt.Sprintf( + "[gray]%s [green]<%s>[white] %s", + timestamp, msg.From, text, + )) +} + +func (a *App) handleJoinEvent( + msg *api.Message, timestamp string, +) { + if msg.To == "" { + return + } + + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [yellow]*** %s has joined %s", + timestamp, msg.From, msg.To, + )) +} + +func (a *App) handlePartEvent( + msg *api.Message, timestamp string, +) { + if msg.To == "" { + return + } + + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + + if reason != "" { + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [yellow]*** %s has left %s (%s)", + timestamp, msg.From, msg.To, reason, + )) + } else { + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [yellow]*** %s has left %s", + timestamp, msg.From, msg.To, + )) + } +} + +func (a *App) handleQuitEvent( + msg *api.Message, timestamp string, +) { + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + + if reason != "" { + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [yellow]*** %s has quit (%s)", + timestamp, msg.From, reason, + )) + } else { + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [yellow]*** %s has quit", + timestamp, msg.From, + )) + } +} + +func (a *App) handleNickEvent( + msg *api.Message, timestamp, myNick string, +) { + lines := msg.BodyLines() + + newNick := "" + if len(lines) > 0 { + newNick = lines[0] + } + + if msg.From == myNick && newNick != "" { + a.mu.Lock() + a.nick = newNick + + target := a.target + a.mu.Unlock() + + a.ui.SetStatus(newNick, target, "connected") + } + + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [yellow]*** %s is now known as %s", + timestamp, msg.From, newNick, + )) +} + +func (a *App) handleNoticeEvent( + msg *api.Message, timestamp string, +) { + lines := msg.BodyLines() + text := strings.Join(lines, " ") + + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [magenta]--%s-- %s", + timestamp, msg.From, text, + )) +} + +func (a *App) handleTopicEvent( + msg *api.Message, timestamp string, +) { + if msg.To == "" { + return + } + + lines := msg.BodyLines() + text := strings.Join(lines, " ") + + a.ui.AddLine(msg.To, fmt.Sprintf( + "[gray]%s [cyan]*** %s set topic: %s", + timestamp, msg.From, text, + )) +} + +func (a *App) handleDefaultEvent( + msg *api.Message, timestamp string, +) { + lines := msg.BodyLines() + text := strings.Join(lines, " ") + + if text != "" { + a.ui.AddStatus(fmt.Sprintf( + "[gray]%s [white][%s] %s", + timestamp, msg.Command, text, + )) + } +} diff --git a/cmd/neoirc-cli/ui.go b/internal/cli/ui.go similarity index 99% rename from cmd/neoirc-cli/ui.go rename to internal/cli/ui.go index a0f1bbb..80f5fc9 100644 --- a/cmd/neoirc-cli/ui.go +++ b/internal/cli/ui.go @@ -1,4 +1,4 @@ -package main +package cli import ( "fmt" diff --git a/internal/config/config.go b/internal/config/config.go index 855b55a..da29f1e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,7 @@ type Config struct { ServerName string FederationKey string SessionIdleTimeout string + HashcashBits int params *Params log *slog.Logger } @@ -76,6 +77,7 @@ func New( viper.SetDefault("SERVER_NAME", "") viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("SESSION_IDLE_TIMEOUT", "720h") + viper.SetDefault("NEOIRC_HASHCASH_BITS", "20") err := viper.ReadInConfig() if err != nil { @@ -101,6 +103,7 @@ func New( ServerName: viper.GetString("SERVER_NAME"), FederationKey: viper.GetString("FEDERATION_KEY"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), + HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"), log: log, params: ¶ms, } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3d7f886..be14efd 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -146,7 +146,8 @@ func (hdlr *Handlers) handleCreateSession( request *http.Request, ) { type createRequest struct { - Nick string `json:"nick"` + Nick string `json:"nick"` + Hashcash string `json:"pow_token,omitempty"` //nolint:tagliatelle } var payload createRequest @@ -162,6 +163,32 @@ func (hdlr *Handlers) handleCreateSession( return } + // Validate hashcash proof-of-work if configured. + if hdlr.params.Config.HashcashBits > 0 { + if payload.Hashcash == "" { + hdlr.respondError( + writer, request, + "hashcash proof-of-work required", + http.StatusPaymentRequired, + ) + + return + } + + err = hdlr.hashcashVal.Validate( + payload.Hashcash, hdlr.params.Config.HashcashBits, + ) + if err != nil { + hdlr.respondError( + writer, request, + "invalid hashcash stamp: "+err.Error(), + http.StatusPaymentRequired, + ) + + return + } + } + payload.Nick = strings.TrimSpace(payload.Nick) if !validNickRe.MatchString(payload.Nick) { @@ -2392,11 +2419,19 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc { return } - hdlr.respondJSON(writer, request, map[string]any{ + resp := map[string]any{ "name": hdlr.params.Config.ServerName, "version": hdlr.params.Globals.Version, "motd": hdlr.params.Config.MOTD, "users": users, - }, http.StatusOK) + } + + if hdlr.params.Config.HashcashBits > 0 { + resp["hashcash_bits"] = hdlr.params.Config.HashcashBits + } + + hdlr.respondJSON( + writer, request, resp, http.StatusOK, + ) } } diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index d8eb7c8..a07cb9f 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -85,6 +85,7 @@ func newTestServer( cfg.DBURL = dbURL cfg.Port = 0 + cfg.HashcashBits = 0 return cfg, nil }, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6d9b7cd..6a76ae3 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -13,6 +13,7 @@ import ( "git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/globals" + "git.eeqj.de/sneak/neoirc/internal/hashcash" "git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/logger" "go.uber.org/fx" @@ -39,6 +40,7 @@ type Handlers struct { log *slog.Logger hc *healthcheck.Healthcheck broker *broker.Broker + hashcashVal *hashcash.Validator cancelCleanup context.CancelFunc } @@ -47,11 +49,17 @@ func New( lifecycle fx.Lifecycle, params Params, ) (*Handlers, error) { + resource := params.Config.ServerName + if resource == "" { + resource = "neoirc" + } + hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup - params: ¶ms, - log: params.Logger.Get(), - hc: params.Healthcheck, - broker: broker.New(), + params: ¶ms, + log: params.Logger.Get(), + hc: params.Healthcheck, + broker: broker.New(), + hashcashVal: hashcash.NewValidator(resource), } lifecycle.Append(fx.Hook{ diff --git a/internal/hashcash/hashcash.go b/internal/hashcash/hashcash.go new file mode 100644 index 0000000..345c20b --- /dev/null +++ b/internal/hashcash/hashcash.go @@ -0,0 +1,277 @@ +// Package hashcash implements SHA-256-based hashcash +// proof-of-work validation for abuse prevention. +// +// Stamp format: 1:bits:YYMMDD:resource::counter. +// +// The SHA-256 hash of the entire stamp string must have +// at least `bits` leading zero bits. +package hashcash + +import ( + "crypto/sha256" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // stampVersion is the only supported hashcash version. + stampVersion = "1" + // stampFields is the number of fields in a stamp. + stampFields = 6 + // maxStampAge is how old a stamp can be before + // rejection. + maxStampAge = 48 * time.Hour + // maxFutureSkew allows stamps slightly in the future. + maxFutureSkew = 1 * time.Hour + // pruneInterval controls how often expired stamps are + // removed from the spent set. + pruneInterval = 10 * time.Minute + // dateFormatShort is the YYMMDD date layout. + dateFormatShort = "060102" + // dateFormatLong is the YYMMDDHHMMSS date layout. + dateFormatLong = "060102150405" + // dateShortLen is the length of YYMMDD. + dateShortLen = 6 + // dateLongLen is the length of YYMMDDHHMMSS. + dateLongLen = 12 + // bitsPerByte is the number of bits in a byte. + bitsPerByte = 8 + // fullByteMask is 0xFF, a mask for all bits in a byte. + fullByteMask = 0xFF +) + +var ( + errInvalidFields = errors.New("invalid stamp field count") + errBadVersion = errors.New("unsupported stamp version") + errInsufficientBits = errors.New("insufficient difficulty") + errWrongResource = errors.New("wrong resource") + errStampExpired = errors.New("stamp expired") + errStampFuture = errors.New("stamp date in future") + errProofFailed = errors.New("proof-of-work failed") + errStampReused = errors.New("stamp already used") + errBadDateFormat = errors.New("unrecognized date format") +) + +// Validator checks hashcash stamps for validity and +// prevents replay attacks via an in-memory spent set. +type Validator struct { + resource string + mu sync.Mutex + spent map[string]time.Time +} + +// NewValidator creates a Validator for the given resource. +func NewValidator(resource string) *Validator { + validator := &Validator{ + resource: resource, + mu: sync.Mutex{}, + spent: make(map[string]time.Time), + } + + go validator.pruneLoop() + + return validator +} + +// Validate checks a hashcash stamp. It returns nil if the +// stamp is valid and has not been seen before. +func (v *Validator) Validate( + stamp string, + requiredBits int, +) error { + if requiredBits <= 0 { + return nil + } + + parts := strings.Split(stamp, ":") + if len(parts) != stampFields { + return fmt.Errorf( + "%w: expected %d, got %d", + errInvalidFields, stampFields, len(parts), + ) + } + + version := parts[0] + bitsStr := parts[1] + dateStr := parts[2] + resource := parts[3] + + if err := v.validateHeader( + version, bitsStr, resource, requiredBits, + ); err != nil { + return err + } + + stampTime, err := parseStampDate(dateStr) + if err != nil { + return err + } + + if err := validateTime(stampTime); err != nil { + return err + } + + if err := validateProof( + stamp, requiredBits, + ); err != nil { + return err + } + + return v.checkAndRecordStamp(stamp, stampTime) +} + +func (v *Validator) validateHeader( + version, bitsStr, resource string, + requiredBits int, +) error { + if version != stampVersion { + return fmt.Errorf( + "%w: %s", errBadVersion, version, + ) + } + + claimedBits, err := strconv.Atoi(bitsStr) + if err != nil || claimedBits < requiredBits { + return fmt.Errorf( + "%w: need %d bits", + errInsufficientBits, requiredBits, + ) + } + + if resource != v.resource { + return fmt.Errorf( + "%w: got %q, want %q", + errWrongResource, resource, v.resource, + ) + } + + return nil +} + +func validateTime(stampTime time.Time) error { + now := time.Now() + + if now.Sub(stampTime) > maxStampAge { + return errStampExpired + } + + if stampTime.Sub(now) > maxFutureSkew { + return errStampFuture + } + + return nil +} + +func validateProof(stamp string, requiredBits int) error { + hash := sha256.Sum256([]byte(stamp)) + if !hasLeadingZeroBits(hash[:], requiredBits) { + return fmt.Errorf( + "%w: need %d leading zero bits", + errProofFailed, requiredBits, + ) + } + + return nil +} + +func (v *Validator) checkAndRecordStamp( + stamp string, + stampTime time.Time, +) error { + v.mu.Lock() + defer v.mu.Unlock() + + if _, ok := v.spent[stamp]; ok { + return errStampReused + } + + v.spent[stamp] = stampTime + + return nil +} + +// hasLeadingZeroBits checks if the hash has at least n +// leading zero bits. +func hasLeadingZeroBits(hash []byte, numBits int) bool { + fullBytes := numBits / bitsPerByte + remainBits := numBits % bitsPerByte + + for idx := range fullBytes { + if hash[idx] != 0 { + return false + } + } + + if remainBits > 0 && fullBytes < len(hash) { + mask := byte( + fullByteMask << (bitsPerByte - remainBits), + ) + + if hash[fullBytes]&mask != 0 { + return false + } + } + + return true +} + +// parseStampDate parses a hashcash date stamp. +// Supports YYMMDD and YYMMDDHHMMSS formats. +func parseStampDate(dateStr string) (time.Time, error) { + switch len(dateStr) { + case dateShortLen: + parsed, err := time.Parse( + dateFormatShort, dateStr, + ) + if err != nil { + return time.Time{}, fmt.Errorf( + "parse date: %w", err, + ) + } + + return parsed, nil + case dateLongLen: + parsed, err := time.Parse( + dateFormatLong, dateStr, + ) + if err != nil { + return time.Time{}, fmt.Errorf( + "parse date: %w", err, + ) + } + + return parsed, nil + default: + return time.Time{}, fmt.Errorf( + "%w: %q", errBadDateFormat, dateStr, + ) + } +} + +// pruneLoop periodically removes expired stamps from the +// spent set. +func (v *Validator) pruneLoop() { + ticker := time.NewTicker(pruneInterval) + defer ticker.Stop() + + for range ticker.C { + v.prune() + } +} + +func (v *Validator) prune() { + cutoff := time.Now().Add(-maxStampAge) + + v.mu.Lock() + defer v.mu.Unlock() + + for stamp, stampTime := range v.spent { + if stampTime.Before(cutoff) { + delete(v.spent, stamp) + } + } +} diff --git a/internal/hashcash/hashcash_test.go b/internal/hashcash/hashcash_test.go new file mode 100644 index 0000000..28dbe9f --- /dev/null +++ b/internal/hashcash/hashcash_test.go @@ -0,0 +1,261 @@ +package hashcash_test + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "testing" + "time" + + "git.eeqj.de/sneak/neoirc/internal/hashcash" +) + +const testBits = 2 + +// mintStampWithDate creates a valid hashcash stamp using +// the given date string. +func mintStampWithDate( + tb testing.TB, + bits int, + resource string, + date string, +) string { + tb.Helper() + + prefix := fmt.Sprintf( + "1:%d:%s:%s::", bits, date, resource, + ) + + for { + counterVal, err := rand.Int( + rand.Reader, big.NewInt(1<<48), + ) + if err != nil { + tb.Fatalf("random counter: %v", err) + } + + stamp := prefix + hex.EncodeToString( + counterVal.Bytes(), + ) + hash := sha256.Sum256([]byte(stamp)) + + if hasLeadingZeroBits(hash[:], bits) { + return stamp + } + } +} + +// hasLeadingZeroBits checks if hash has at least numBits +// leading zero bits. Duplicated here for test minting. +func hasLeadingZeroBits( + hash []byte, + numBits int, +) bool { + fullBytes := numBits / 8 + remainBits := numBits % 8 + + for idx := range fullBytes { + if hash[idx] != 0 { + return false + } + } + + if remainBits > 0 && fullBytes < len(hash) { + mask := byte(0xFF << (8 - remainBits)) + + if hash[fullBytes]&mask != 0 { + return false + } + } + + return true +} + +func todayDate() string { + return time.Now().UTC().Format("060102") +} + +func TestMintAndValidate(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + stamp := mintStampWithDate( + t, testBits, "test-resource", todayDate(), + ) + + err := validator.Validate(stamp, testBits) + if err != nil { + t.Fatalf("valid stamp rejected: %v", err) + } +} + +func TestReplayDetection(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + stamp := mintStampWithDate( + t, testBits, "test-resource", todayDate(), + ) + + err := validator.Validate(stamp, testBits) + if err != nil { + t.Fatalf("first use failed: %v", err) + } + + err = validator.Validate(stamp, testBits) + if err == nil { + t.Fatal("replay not detected") + } +} + +func TestResourceMismatch(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("correct-resource") + stamp := mintStampWithDate( + t, testBits, "wrong-resource", todayDate(), + ) + + err := validator.Validate(stamp, testBits) + if err == nil { + t.Fatal("expected resource mismatch error") + } +} + +func TestInvalidStampFormat(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + + err := validator.Validate( + "not:a:valid:stamp", testBits, + ) + if err == nil { + t.Fatal("expected error for bad format") + } +} + +func TestBadVersion(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + stamp := fmt.Sprintf( + "2:%d:%s:%s::abc123", + testBits, todayDate(), "test-resource", + ) + + err := validator.Validate(stamp, testBits) + if err == nil { + t.Fatal("expected bad version error") + } +} + +func TestInsufficientDifficulty(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + // Claimed bits=1, but we require testBits=2. + stamp := fmt.Sprintf( + "1:1:%s:%s::counter", + todayDate(), "test-resource", + ) + + err := validator.Validate(stamp, testBits) + if err == nil { + t.Fatal("expected insufficient bits error") + } +} + +func TestExpiredStamp(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + oldDate := time.Now().Add(-72 * time.Hour). + UTC().Format("060102") + stamp := mintStampWithDate( + t, testBits, "test-resource", oldDate, + ) + + err := validator.Validate(stamp, testBits) + if err == nil { + t.Fatal("expected expired stamp error") + } +} + +func TestZeroBitsSkipsValidation(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + + err := validator.Validate("garbage", 0) + if err != nil { + t.Fatalf("zero bits should skip: %v", err) + } +} + +func TestLongDateFormat(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + longDate := time.Now().UTC().Format("060102150405") + stamp := mintStampWithDate( + t, testBits, "test-resource", longDate, + ) + + err := validator.Validate(stamp, testBits) + if err != nil { + t.Fatalf("long date stamp rejected: %v", err) + } +} + +func TestBadDateFormat(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + stamp := fmt.Sprintf( + "1:%d:BADDATE:%s::counter", + testBits, "test-resource", + ) + + err := validator.Validate(stamp, testBits) + if err == nil { + t.Fatal("expected bad date error") + } +} + +func TestMultipleUniqueStamps(t *testing.T) { + t.Parallel() + + validator := hashcash.NewValidator("test-resource") + + for range 5 { + stamp := mintStampWithDate( + t, testBits, "test-resource", todayDate(), + ) + + err := validator.Validate(stamp, testBits) + if err != nil { + t.Fatalf("unique stamp rejected: %v", err) + } + } +} + +func TestHigherBitsStillValid(t *testing.T) { + t.Parallel() + + // Mint with bits=4 but validate requiring only 2. + validator := hashcash.NewValidator("test-resource") + stamp := mintStampWithDate( + t, 4, "test-resource", todayDate(), + ) + + err := validator.Validate(stamp, testBits) + if err != nil { + t.Fatalf( + "higher-difficulty stamp rejected: %v", + err, + ) + } +} diff --git a/web/src/app.jsx b/web/src/app.jsx index b204951..2bea255 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -8,6 +8,56 @@ const MEMBER_REFRESH_INTERVAL = 10000; const ACTION_PREFIX = "\x01ACTION "; const ACTION_SUFFIX = "\x01"; +// Hashcash proof-of-work helpers using Web Crypto API. + +function checkLeadingZeros(hashBytes, bits) { + let count = 0; + for (let i = 0; i < hashBytes.length; i++) { + if (hashBytes[i] === 0) { + count += 8; + continue; + } + let b = hashBytes[i]; + while ((b & 0x80) === 0) { + count++; + b <<= 1; + } + break; + } + return count >= bits; +} + +async function mintHashcash(bits, resource) { + const encoder = new TextEncoder(); + const now = new Date(); + const date = + String(now.getUTCFullYear()).slice(2) + + String(now.getUTCMonth() + 1).padStart(2, "0") + + String(now.getUTCDate()).padStart(2, "0"); + const prefix = `1:${bits}:${date}:${resource}::`; + let nonce = Math.floor(Math.random() * 0x100000); + const batchSize = 1024; + + for (;;) { + const stamps = []; + const hashPromises = []; + for (let i = 0; i < batchSize; i++) { + const stamp = prefix + (nonce + i).toString(16); + stamps.push(stamp); + hashPromises.push( + crypto.subtle.digest("SHA-256", encoder.encode(stamp)), + ); + } + const hashes = await Promise.all(hashPromises); + for (let i = 0; i < hashes.length; i++) { + if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) { + return stamps[i]; + } + } + nonce += batchSize; + } +} + function api(path, opts = {}) { const token = localStorage.getItem("neoirc_token"); const headers = { @@ -60,12 +110,16 @@ function LoginScreen({ onLogin }) { const [motd, setMotd] = useState(""); const [serverName, setServerName] = useState("NeoIRC"); const inputRef = useRef(); + const hashcashBitsRef = useRef(0); + const hashcashResourceRef = useRef("neoirc"); useEffect(() => { api("/server") .then((s) => { if (s.name) setServerName(s.name); if (s.motd) setMotd(s.motd); + hashcashBitsRef.current = s.hashcash_bits || 0; + if (s.name) hashcashResourceRef.current = s.name; }) .catch(() => {}); const saved = localStorage.getItem("neoirc_token"); @@ -81,9 +135,22 @@ function LoginScreen({ onLogin }) { e.preventDefault(); setError(""); try { + let hashcashStamp = ""; + if (hashcashBitsRef.current > 0) { + setError("Computing proof-of-work..."); + hashcashStamp = await mintHashcash( + hashcashBitsRef.current, + hashcashResourceRef.current, + ); + setError(""); + } + const reqBody = { nick: nick.trim() }; + if (hashcashStamp) { + reqBody.pow_token = hashcashStamp; + } const res = await api("/session", { method: "POST", - body: JSON.stringify({ nick: nick.trim() }), + body: JSON.stringify(reqBody), }); localStorage.setItem("neoirc_token", res.token); onLogin(res.nick);