diff --git a/README.md b/README.md index bdf76e4..8b00bfb 100644 --- a/README.md +++ b/README.md @@ -989,23 +989,18 @@ Create a new user session. This is the entry point for all clients. 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 `X-Hashcash` request header. The required difficulty is -advertised via `GET /api/v1/server` in the `hashcash_bits` field. - -**Request Headers:** - -| Header | Required | Description | -|--------------|----------|-------------| -| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | +valid stamp in the `hashcash` 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", "hashcash": "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 | +| `hashcash` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | **Response:** `201 Created` ```json @@ -1027,7 +1022,7 @@ advertised via `GET /api/v1/server` in the `hashcash_bits` field. | Status | Error | When | |--------|-------|------| | 400 | `nick must be 1-32 characters` | Empty or too-long nick | -| 402 | `hashcash proof-of-work required` | Missing `X-Hashcash` header when hashcash is enabled | +| 402 | `hashcash proof-of-work required` | Missing `hashcash` 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 | @@ -1035,8 +1030,7 @@ advertised via `GET /api/v1/server` in the `hashcash_bits` field. ```bash TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ -H 'Content-Type: application/json' \ - -H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \ - -d '{"nick":"alice"}' | jq -r .token) + -d '{"nick":"alice","hashcash":"1:20:260310:neoirc::3a2f1"}' | jq -r .token) echo $TOKEN ``` @@ -2138,7 +2132,7 @@ account registration, no IP-based rate limits that punish shared networks. 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 `X-Hashcash` request header when creating +3. Client includes the stamp in the `hashcash` field of the JSON request body when creating a session: `POST /api/v1/session`. 4. Server validates the stamp: - Version is `1` @@ -2195,7 +2189,7 @@ 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 `X-Hashcash` header on `POST /api/v1/session` +3. Include the stamp in the `hashcash` 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`. diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index dc37426..1191d25 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -52,7 +52,7 @@ func (client *Client) CreateSession( // Fetch server info to check for hashcash requirement. info, err := client.GetServerInfo() - var headers map[string]string + var hashcashStamp string if err == nil && info.HashcashBits > 0 { resource := info.Name @@ -60,17 +60,13 @@ func (client *Client) CreateSession( resource = "neoirc" } - stamp := MintHashcash(info.HashcashBits, resource) - headers = map[string]string{ - "X-Hashcash": stamp, - } + hashcashStamp = MintHashcash(info.HashcashBits, resource) } - data, err := client.doWithHeaders( + data, err := client.do( http.MethodPost, "/api/v1/session", - &SessionRequest{Nick: nick}, - headers, + &SessionRequest{Nick: nick, Hashcash: hashcashStamp}, ) if err != nil { return nil, err diff --git a/internal/cli/api/types.go b/internal/cli/api/types.go index 707ae82..7a8714b 100644 --- a/internal/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:"hashcash,omitempty"` } // SessionResponse is the response from session creation. diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 0a3a250..b6821de 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -144,35 +144,9 @@ 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"` + Nick string `json:"nick"` + Hashcash string `json:"hashcash,omitempty"` } var payload createRequest @@ -188,6 +162,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) { diff --git a/web/src/app.jsx b/web/src/app.jsx index df65c4e..c2eb2db 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -135,20 +135,22 @@ function LoginScreen({ onLogin }) { e.preventDefault(); setError(""); try { - const extraHeaders = {}; + let hashcashStamp = ""; if (hashcashBitsRef.current > 0) { setError("Computing proof-of-work..."); - const stamp = await mintHashcash( + hashcashStamp = await mintHashcash( hashcashBitsRef.current, hashcashResourceRef.current, ); - extraHeaders["X-Hashcash"] = stamp; setError(""); } + const reqBody = { nick: nick.trim() }; + if (hashcashStamp) { + reqBody.hashcash = hashcashStamp; + } const res = await api("/session", { method: "POST", - body: JSON.stringify({ nick: nick.trim() }), - headers: extraHeaders, + body: JSON.stringify(reqBody), }); localStorage.setItem("neoirc_token", res.token); onLogin(res.nick);