All checks were successful
check / check (push) Successful in 1m2s
## 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>
80 lines
1.6 KiB
Go
80 lines
1.6 KiB
Go
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())
|
|
}
|