feat: implement hashcash proof-of-work for session creation (#63)
All checks were successful
check / check (push) Successful in 1m2s
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>
This commit was merged in pull request #63.
This commit is contained in:
261
internal/hashcash/hashcash_test.go
Normal file
261
internal/hashcash/hashcash_test.go
Normal file
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user