Files
chat/internal/cli/api/client.go
clawbot 75cecd9803
All checks were successful
check / check (push) Successful in 1m2s
feat: implement hashcash proof-of-work for session creation (#63)
## Summary

Implement SHA-256-based hashcash proof-of-work for `POST /session` to prevent abuse via rapid session creation.

closes #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)

<!-- session: agent:sdlc-manager:subagent:f98d712e-8a40-4013-b3d7-588cbff670f4 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.eeqj.de>
Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #63
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-13 00:38:41 +01:00

334 lines
6.2 KiB
Go

// Package neoircapi provides a client for the neoirc server API.
package neoircapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.eeqj.de/sneak/neoirc/pkg/irc"
)
const (
httpTimeout = 30 * time.Second
pollExtraTime = 5
httpErrThreshold = 400
)
var errHTTP = errors.New("HTTP error")
// Client wraps HTTP calls to the neoirc server API.
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// NewClient creates a new API client.
func NewClient(baseURL string) *Client {
return &Client{ //nolint:exhaustruct // Token set after CreateSession
BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: httpTimeout,
},
}
}
// 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, Hashcash: hashcashStamp},
)
if err != nil {
return nil, err
}
var resp SessionResponse
err = json.Unmarshal(data, &resp)
if err != nil {
return nil, fmt.Errorf("decode session: %w", err)
}
client.Token = resp.Token
return &resp, nil
}
// GetState returns the current user state.
func (client *Client) GetState() (*StateResponse, error) {
data, err := client.do(
http.MethodGet, "/api/v1/state", nil,
)
if err != nil {
return nil, err
}
var resp StateResponse
err = json.Unmarshal(data, &resp)
if err != nil {
return nil, fmt.Errorf("decode state: %w", err)
}
return &resp, nil
}
// SendMessage sends a message (any IRC command).
func (client *Client) SendMessage(msg *Message) error {
_, err := client.do(
http.MethodPost, "/api/v1/messages", msg,
)
return err
}
// PollMessages long-polls for new messages.
func (client *Client) PollMessages(
afterID int64,
timeout int,
) (*PollResult, error) {
pollClient := &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: time.Duration(
timeout+pollExtraTime,
) * time.Second,
}
params := url.Values{}
if afterID > 0 {
params.Set(
"after",
strconv.FormatInt(afterID, 10),
)
}
params.Set("timeout", strconv.Itoa(timeout))
path := "/api/v1/messages?" + params.Encode()
request, err := http.NewRequestWithContext(
context.Background(),
http.MethodGet,
client.BaseURL+path,
nil,
)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
resp, err := pollClient.Do(request)
if err != nil {
return nil, fmt.Errorf("poll request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read poll body: %w", err)
}
if resp.StatusCode >= httpErrThreshold {
return nil, fmt.Errorf(
"%w %d: %s",
errHTTP, resp.StatusCode, string(data),
)
}
var wrapped MessagesResponse
err = json.Unmarshal(data, &wrapped)
if err != nil {
return nil, fmt.Errorf(
"decode messages: %w", err,
)
}
return &PollResult{
Messages: wrapped.Messages,
LastID: wrapped.LastID,
}, nil
}
// JoinChannel joins a channel.
func (client *Client) JoinChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel,
},
)
}
// PartChannel leaves a channel.
func (client *Client) PartChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdPart, To: channel,
},
)
}
// ListChannels returns all channels on the server.
func (client *Client) ListChannels() (
[]Channel, error,
) {
data, err := client.do(
http.MethodGet, "/api/v1/channels", nil,
)
if err != nil {
return nil, err
}
var channels []Channel
err = json.Unmarshal(data, &channels)
if err != nil {
return nil, fmt.Errorf(
"decode channels: %w", err,
)
}
return channels, nil
}
// GetMembers returns members of a channel.
func (client *Client) GetMembers(
channel string,
) ([]string, error) {
name := strings.TrimPrefix(channel, "#")
data, err := client.do(
http.MethodGet,
"/api/v1/channels/"+url.PathEscape(name)+
"/members",
nil,
)
if err != nil {
return nil, err
}
var members []string
err = json.Unmarshal(data, &members)
if err != nil {
return nil, fmt.Errorf(
"unexpected members format: %w", err,
)
}
return members, nil
}
// GetServerInfo returns server info.
func (client *Client) GetServerInfo() (
*ServerInfo, error,
) {
data, err := client.do(
http.MethodGet, "/api/v1/server", nil,
)
if err != nil {
return nil, err
}
var info ServerInfo
err = json.Unmarshal(data, &info)
if err != nil {
return nil, fmt.Errorf(
"decode server info: %w", err,
)
}
return &info, nil
}
func (client *Client) do(
method, path string,
body any,
) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
bodyReader = bytes.NewReader(data)
}
request, err := http.NewRequestWithContext(
context.Background(),
method,
client.BaseURL+path,
bodyReader,
)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
request.Header.Set(
"Content-Type", "application/json",
)
if client.Token != "" {
request.Header.Set(
"Authorization", "Bearer "+client.Token,
)
}
resp, err := client.HTTPClient.Do(request)
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode >= httpErrThreshold {
return data, fmt.Errorf(
"%w %d: %s",
errHTTP, resp.StatusCode, string(data),
)
}
return data, nil
}