feat: implement hashcash proof-of-work for session creation

Add SHA-256-based hashcash proof-of-work requirement to POST /session
to prevent abuse via rapid session creation. The server advertises the
required difficulty via GET /server (hashcash_bits field), and clients
must include a valid stamp in the X-Hashcash request header.

Server-side:
- New internal/hashcash package with stamp validation (format, bits,
  date, resource, replay prevention via in-memory spent set)
- Config: NEOIRC_HASHCASH_BITS env var (default 20, set 0 to disable)
- GET /server includes hashcash_bits when > 0
- POST /session validates X-Hashcash header when enabled
- Returns HTTP 402 for missing/invalid stamps

Client-side:
- SPA: fetches hashcash_bits from /server, computes stamp using Web
  Crypto API with batched SHA-256, shows 'Computing proof-of-work...'
  feedback during computation
- CLI: api package gains MintHashcash() function, CreateSession()
  auto-fetches server info and computes stamp when required

Stamp format: 1:bits:YYMMDD:resource::counter (standard hashcash)

closes #11
This commit is contained in:
clawbot
2026-03-10 02:51:15 -07:00
parent a98e0ca349
commit a89393186f
9 changed files with 558 additions and 88 deletions

View File

@@ -144,6 +144,33 @@ func (hdlr *Handlers) handleCreateSession(
writer http.ResponseWriter,
request *http.Request,
) {
// Validate hashcash proof-of-work if configured.
if hdlr.params.Config.HashcashBits > 0 {
stamp := request.Header.Get("X-Hashcash")
if stamp == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return
}
err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return
}
}
type createRequest struct {
Nick string `json:"nick"`
}
@@ -2391,11 +2418,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,
)
}
}

View File

@@ -85,6 +85,7 @@ func newTestServer(
cfg.DBURL = dbURL
cfg.Port = 0
cfg.HashcashBits = 0
return cfg, nil
},

View File

@@ -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: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
params: &params,
log: params.Logger.Get(),
hc: params.Healthcheck,
broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
}
lifecycle.Append(fx.Hook{