From 2a4060bc8afa2b1e007918c727ea9224f5666490 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 16:40:03 +0700 Subject: [PATCH 01/14] Add PoW challenge structure and types --- docs/IMPLEMENTATION.md | 4 +- internal/pow/types.go | 134 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 internal/pow/types.go diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index b614357..a522ed7 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -4,8 +4,8 @@ **Goal**: Create standalone, testable PoW package with HMAC-signed stateless challenges - [ ] **Project Setup** - - [ ] Initialize Go module and basic project structure - - [ ] Create PoW challenge structure and types + - [X] Initialize Go module and basic project structure + - [X] Create PoW challenge structure and types - [ ] Set up testing framework and utilities - [ ] **Challenge Generation & HMAC Security** diff --git a/internal/pow/types.go b/internal/pow/types.go new file mode 100644 index 0000000..71f49d5 --- /dev/null +++ b/internal/pow/types.go @@ -0,0 +1,134 @@ +package pow + +import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "fmt" + "time" +) + +// PoW package specific errors +var ( + ErrInvalidHMAC = errors.New("challenge HMAC signature is invalid") + ErrExpiredChallenge = errors.New("challenge has expired") + ErrInvalidSolution = errors.New("proof of work solution is invalid") + ErrInvalidDifficulty = errors.New("difficulty level is invalid") + ErrMalformedChallenge = errors.New("challenge format is malformed") + ErrInvalidConfig = errors.New("configuration is invalid") +) + +// Challenge represents a Proof of Work challenge with HMAC authentication +type Challenge struct { + Timestamp int64 `json:"timestamp"` + Difficulty int `json:"difficulty"` + Resource string `json:"resource"` + Random []byte `json:"random"` + HMAC []byte `json:"hmac"` +} + +// CanonicalBytes returns the canonical byte representation for HMAC signing +func (c *Challenge) CanonicalBytes() []byte { + // Use consistent field ordering: timestamp:difficulty:resource:random + buf := make([]byte, 0, 64) + buf = fmt.Appendf(buf, "%d:%d:%s:%x", + c.Timestamp, + c.Difficulty, + c.Resource, + c.Random, + ) + return buf +} + +// BuildSolutionString creates the string used for PoW computation +func (c *Challenge) BuildSolutionString(nonce uint64) []byte { + // Format: resource:timestamp:difficulty:random:nonce + buf := make([]byte, 0, 64) + buf = fmt.Appendf(buf, "%s:%d:%d:%x:%d", + c.Resource, + c.Timestamp, + c.Difficulty, + c.Random, + nonce, + ) + return buf +} + +// VerifySolution verifies if a solution is valid for this challenge +func (c *Challenge) VerifySolution(nonce uint64) bool { + // Build solution string + solutionStr := c.BuildSolutionString(nonce) + + // Compute SHA-256 hash + hash := sha256.Sum256(solutionStr) + + // Check if hash has required leading zero bits + return hasLeadingZeroBits(hash[:], c.Difficulty) +} + +// hasLeadingZeroBits checks if hash has the required number of leading zero bits +func hasLeadingZeroBits(hash []byte, difficulty int) bool { + full := difficulty >> 3 // number of whole zero bytes + rem := uint(difficulty & 7) // remaining leading zero bits + + for i := range full { + if hash[i] != 0 { + return false + } + } + if rem == 0 { + return true + } + mask := byte(0xFF) << (8 - rem) // e.g., rem=3 => 11100000 + return (hash[full] & mask) == 0 +} + +// IsExpired checks if challenge has exceeded TTL +func (c *Challenge) IsExpired(ttl time.Duration) bool { + challengeTime := time.Unix(c.Timestamp, 0) + return time.Since(challengeTime) > ttl +} + +// Sign creates and sets HMAC signature for the challenge +func (c *Challenge) Sign(secret []byte) { + // Create canonical bytes for signing (excluding HMAC field) + canonical := c.CanonicalBytes() + + // Create HMAC + h := hmac.New(sha256.New, secret) + h.Write(canonical) + c.HMAC = h.Sum(nil) +} + +// VerifyHMAC verifies the HMAC signature of the challenge +func (c *Challenge) VerifyHMAC(secret []byte) error { + if len(c.HMAC) == 0 { + return ErrMalformedChallenge + } + + // Create canonical bytes (excluding HMAC) + canonical := c.CanonicalBytes() + + // Compute expected HMAC + h := hmac.New(sha256.New, secret) + h.Write(canonical) + expectedSignature := h.Sum(nil) + + // Compare signatures + if !hmac.Equal(c.HMAC, expectedSignature) { + return ErrInvalidHMAC + } + + return nil +} + +// Solution represents a client's solution to a PoW challenge +type Solution struct { + Challenge Challenge `json:"challenge"` + Nonce uint64 `json:"nonce"` +} + +// Verify verifies if this solution is valid +func (s *Solution) Verify() bool { + return s.Challenge.VerifySolution(s.Nonce) +} -- 2.44.1 From 47d1280556f599b40d02335cce7279620c573112 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 16:48:14 +0700 Subject: [PATCH 02/14] Set up testing framework and utilities --- docs/IMPLEMENTATION.md | 4 +-- go.mod | 2 ++ go.sum | 2 ++ internal/pow/test_utils.go | 63 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 go.sum create mode 100644 internal/pow/test_utils.go diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index a522ed7..87bab66 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -3,10 +3,10 @@ ## Phase 1: Proof of Work Package Implementation **Goal**: Create standalone, testable PoW package with HMAC-signed stateless challenges -- [ ] **Project Setup** +- [X] **Project Setup** - [X] Initialize Go module and basic project structure - [X] Create PoW challenge structure and types - - [ ] Set up testing framework and utilities + - [X] Set up testing framework and utilities - [ ] **Challenge Generation & HMAC Security** - [ ] Implement HMAC-signed challenge generation (stateless) diff --git a/go.mod b/go.mod index 54ac4fd..96cd041 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module word-of-wisdom go 1.24.3 + +require github.com/stretchr/testify v1.10.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7bfdabe --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= diff --git a/internal/pow/test_utils.go b/internal/pow/test_utils.go new file mode 100644 index 0000000..1cbe69a --- /dev/null +++ b/internal/pow/test_utils.go @@ -0,0 +1,63 @@ +package pow + +import ( + "crypto/rand" + "encoding/hex" + "time" +) + +// TestConfig returns a configuration suitable for testing +func TestConfig() *Config { + secret := make([]byte, 32) + rand.Read(secret) + + config, err := NewConfig( + WithHMACSecret(secret), + WithDefaultDifficulty(4), + WithRandomBytes(6), + ) + if err != nil { + panic("Failed to create test config: " + err.Error()) + } + return config +} + +// TestChallenge returns a valid challenge for testing +func TestChallenge() *Challenge { + return &Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + HMAC: nil, // To be filled by generator + } +} + +// TestSolution returns a valid solution for testing +func TestSolution() *Solution { + return &Solution{ + Challenge: *TestChallenge(), + Nonce: 42, + } +} + +// RandomHex generates a random hex string of given length +func RandomHex(length int) string { + bytes := make([]byte, length/2) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// ExpiredChallenge returns a challenge that has expired +func ExpiredChallenge() *Challenge { + challenge := TestChallenge() + challenge.Timestamp = time.Now().Add(-10 * time.Minute).Unix() + return challenge +} + +// FutureChallenge returns a challenge with future timestamp +func FutureChallenge() *Challenge { + challenge := TestChallenge() + challenge.Timestamp = time.Now().Add(10 * time.Minute).Unix() + return challenge +} -- 2.44.1 From 7d521cfccf9b1eda1f3eee2af79c6d47c56007e5 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 17:10:23 +0700 Subject: [PATCH 03/14] Update protocol spec --- docs/PROTOCOL.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1c27038..aa104d5 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -97,22 +97,20 @@ All protocol messages use a binary format with the following structure: - **Format**: ```json { - "id": "challenge_unique_id", "timestamp": 1640995200, "difficulty": 4, - "resource": "192.168.1.100:8080", + "resource": "quotes", "random": "a1b2c3d4e5f6", "hmac": "base64url_encoded_signature" } ``` **Field Descriptions**: -- **id**: Unique identifier for this challenge - **timestamp**: Unix timestamp when challenge was created - **difficulty**: Number of leading zero bits required in solution hash -- **resource**: Server resource identifier (typically IP:port) +- **resource**: Server resource identifier (e.g., "quotes") - **random**: Random hex string for challenge uniqueness -- **hmac**: HMAC-SHA256 signature of canonical challenge fields +- **hmac**: HMAC-SHA256 signature of canonical challenge fields (also serves as unique identifier) **Security Notes**: - Server is **stateless**: no need to store challenges locally @@ -126,10 +124,9 @@ All protocol messages use a binary format with the following structure: ```json { "challenge": { - "id": "challenge_unique_id", "timestamp": 1640995200, "difficulty": 4, - "resource": "192.168.1.100:8080", + "resource": "quotes", "random": "a1b2c3d4e5f6", "hmac": "base64url_encoded_signature" }, -- 2.44.1 From 2d7a2bfaa382b13278ffc0641a6991ad90190568 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 17:14:54 +0700 Subject: [PATCH 04/14] Add pre-commit hook --- .pre-commit-config.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9686e2b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] # сохранить двойной пробел в markdown как перенос + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] -- 2.44.1 From 19ebf54673341f83af2d6274a8fab2bba489f79c Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 17:44:31 +0700 Subject: [PATCH 05/14] Add challenge generator --- docs/IMPLEMENTATION.md | 2 +- internal/pow/generator.go | 62 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 internal/pow/generator.go diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 87bab66..4741443 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -9,7 +9,7 @@ - [X] Set up testing framework and utilities - [ ] **Challenge Generation & HMAC Security** - - [ ] Implement HMAC-signed challenge generation (stateless) + - [X] Implement HMAC-signed challenge generation (stateless) - [ ] Create challenge authenticity verification - [ ] Add timestamp validation for replay protection (5 minutes TTL) - [ ] Implement canonical challenge field ordering for HMAC diff --git a/internal/pow/generator.go b/internal/pow/generator.go new file mode 100644 index 0000000..52bea82 --- /dev/null +++ b/internal/pow/generator.go @@ -0,0 +1,62 @@ +package pow + +import ( + "crypto/rand" + "time" +) + +// Generator handles creation of PoW challenges with HMAC signing +type Generator struct { + config *Config +} + +// GenerateOption represents a functional option for challenge generation +type generateOption func(*Challenge) + +// WithDifficulty sets custom difficulty for the challenge +func WithDifficulty(difficulty int) generateOption { + return func(c *Challenge) { + c.Difficulty = difficulty + } +} + +// NewGenerator creates a new challenge generator +func NewGenerator(config *Config) *Generator { + return &Generator{ + config: config, + } +} + +// GenerateChallenge creates a new HMAC-signed PoW challenge +func (g *Generator) GenerateChallenge(opts ...generateOption) (*Challenge, error) { + // Create challenge with defaults (no random data yet) + challenge := &Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: g.config.DefaultDifficulty, + Resource: g.config.Resource, + } + + // Apply options + for _, opt := range opts { + opt(challenge) + } + + // Validate difficulty bounds + if challenge.Difficulty < g.config.MinDifficulty || challenge.Difficulty > g.config.MaxDifficulty { + return nil, ErrInvalidDifficulty + } + + // Generate cryptographic random data + challenge.Random = g.generateRandom(g.config.RandomBytes) + + // Sign the challenge with HMAC + challenge.Sign(g.config.HMACSecret) + return challenge, nil +} + +// generateRandom creates cryptographic random bytes +func (g *Generator) generateRandom(length int) []byte { + bytes := make([]byte, length) + rand.Read(bytes) + return bytes +} -- 2.44.1 From 84844cdb66b9f4c9ce4f6f7ef50d4b0858f89b1b Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 18:06:52 +0700 Subject: [PATCH 06/14] Add challenge verifier --- internal/pow/verifier.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 internal/pow/verifier.go diff --git a/internal/pow/verifier.go b/internal/pow/verifier.go new file mode 100644 index 0000000..d9a2018 --- /dev/null +++ b/internal/pow/verifier.go @@ -0,0 +1,28 @@ +package pow + +// Verifier handles validation of PoW challenges +type Verifier struct { + config *Config +} + +// NewVerifier creates a new challenge verifier +func NewVerifier(config *Config) *Verifier { + return &Verifier{ + config: config, + } +} + +// VerifyChallenge validates challenge authenticity and expiration +func (v *Verifier) VerifyChallenge(challenge *Challenge) error { + // Check expiration first (cheap operation) + if challenge.IsExpired(v.config.ChallengeTTL) { + return ErrExpiredChallenge + } + + // Check HMAC signature (expensive operation) + if err := challenge.VerifyHMAC(v.config.HMACSecret); err != nil { + return err + } + + return nil +} -- 2.44.1 From c5dcc2acce9b04105409619d6980692343cd0666 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 18:09:51 +0700 Subject: [PATCH 07/14] Move current code to separate package --- internal/pow/{ => challenge}/generator.go | 2 +- internal/pow/{ => challenge}/test_utils.go | 2 +- internal/pow/{ => challenge}/types.go | 2 +- internal/pow/{ => challenge}/verifier.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename internal/pow/{ => challenge}/generator.go (98%) rename internal/pow/{ => challenge}/test_utils.go (98%) rename internal/pow/{ => challenge}/types.go (99%) rename internal/pow/{ => challenge}/verifier.go (97%) diff --git a/internal/pow/generator.go b/internal/pow/challenge/generator.go similarity index 98% rename from internal/pow/generator.go rename to internal/pow/challenge/generator.go index 52bea82..bd07901 100644 --- a/internal/pow/generator.go +++ b/internal/pow/challenge/generator.go @@ -1,4 +1,4 @@ -package pow +package challenge import ( "crypto/rand" diff --git a/internal/pow/test_utils.go b/internal/pow/challenge/test_utils.go similarity index 98% rename from internal/pow/test_utils.go rename to internal/pow/challenge/test_utils.go index 1cbe69a..d33da98 100644 --- a/internal/pow/test_utils.go +++ b/internal/pow/challenge/test_utils.go @@ -1,4 +1,4 @@ -package pow +package challenge import ( "crypto/rand" diff --git a/internal/pow/types.go b/internal/pow/challenge/types.go similarity index 99% rename from internal/pow/types.go rename to internal/pow/challenge/types.go index 71f49d5..83b8298 100644 --- a/internal/pow/types.go +++ b/internal/pow/challenge/types.go @@ -1,4 +1,4 @@ -package pow +package challenge import ( "crypto/hmac" diff --git a/internal/pow/verifier.go b/internal/pow/challenge/verifier.go similarity index 97% rename from internal/pow/verifier.go rename to internal/pow/challenge/verifier.go index d9a2018..0942543 100644 --- a/internal/pow/verifier.go +++ b/internal/pow/challenge/verifier.go @@ -1,4 +1,4 @@ -package pow +package challenge // Verifier handles validation of PoW challenges type Verifier struct { -- 2.44.1 From 859bd989be2c70b7252dfaea388c4d0815eafa41 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 18:57:48 +0700 Subject: [PATCH 08/14] Add pow config --- internal/pow/challenge/config.go | 147 +++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 internal/pow/challenge/config.go diff --git a/internal/pow/challenge/config.go b/internal/pow/challenge/config.go new file mode 100644 index 0000000..c1d8951 --- /dev/null +++ b/internal/pow/challenge/config.go @@ -0,0 +1,147 @@ +package challenge + +import ( + "crypto/rand" + "fmt" + "time" +) + +// Config holds configuration for the PoW system +type Config struct { + DefaultDifficulty int // Default difficulty in bits (e.g., 4) + MaxDifficulty int // Maximum allowed difficulty (e.g., 10) + MinDifficulty int // Minimum allowed difficulty (e.g., 3) + ChallengeTTL time.Duration // Time-to-live for challenges (e.g., 5 minutes) + HMACSecret []byte // Secret key for HMAC signing + Resource string // Resource identifier (e.g., "quotes") + RandomBytes int // Number of random bytes in challenge (e.g., 6) + LoadAdjustmentBits int // Extra difficulty bits when server under load + FailurePenaltyBits int // Extra difficulty bits per failure group + MaxFailurePenaltyBits int // Maximum failure penalty bits +} + +// configOption represents a functional option for Config +type configOption func(*Config) + +// WithDefaultDifficulty sets the default difficulty +func WithDefaultDifficulty(difficulty int) configOption { + return func(c *Config) { + c.DefaultDifficulty = difficulty + } +} + +// WithMaxDifficulty sets the maximum difficulty +func WithMaxDifficulty(difficulty int) configOption { + return func(c *Config) { + c.MaxDifficulty = difficulty + } +} + +// WithMinDifficulty sets the minimum difficulty +func WithMinDifficulty(difficulty int) configOption { + return func(c *Config) { + c.MinDifficulty = difficulty + } +} + +// WithChallengeTTL sets the challenge time-to-live +func WithChallengeTTL(ttl time.Duration) configOption { + return func(c *Config) { + c.ChallengeTTL = ttl + } +} + +// WithHMACSecret sets the HMAC secret +func WithHMACSecret(secret []byte) configOption { + return func(c *Config) { + c.HMACSecret = secret + } +} + +// WithResource sets the resource identifier +func WithResource(resource string) configOption { + return func(c *Config) { + c.Resource = resource + } +} + +// WithRandomBytes sets the number of random bytes +func WithRandomBytes(bytes int) configOption { + return func(c *Config) { + c.RandomBytes = bytes + } +} + +// WithLoadAdjustmentBits sets the load adjustment bits +func WithLoadAdjustmentBits(bits int) configOption { + return func(c *Config) { + c.LoadAdjustmentBits = bits + } +} + +// WithFailurePenaltyBits sets the failure penalty bits +func WithFailurePenaltyBits(bits int) configOption { + return func(c *Config) { + c.FailurePenaltyBits = bits + } +} + +// WithMaxFailurePenaltyBits sets the maximum failure penalty bits +func WithMaxFailurePenaltyBits(bits int) configOption { + return func(c *Config) { + c.MaxFailurePenaltyBits = bits + } +} + +// NewConfig creates and validates a new PoW configuration with defaults +func NewConfig(opts ...configOption) (*Config, error) { + // Generate default HMAC secret + secret := make([]byte, 32) + rand.Read(secret) + + // Create config with defaults + config := &Config{ + DefaultDifficulty: 4, + MaxDifficulty: 10, + MinDifficulty: 3, + ChallengeTTL: 5 * time.Minute, + HMACSecret: secret, + Resource: "quotes", + RandomBytes: 6, + LoadAdjustmentBits: 1, + FailurePenaltyBits: 2, + MaxFailurePenaltyBits: 6, + } + + // Apply options + for _, opt := range opts { + opt(config) + } + + // Validate configuration + if config.RandomBytes <= 0 || config.RandomBytes > 32 { + return nil, fmt.Errorf("%w: random bytes must be between 1-32, got %d", ErrInvalidConfig, config.RandomBytes) + } + + if config.MinDifficulty < 1 { + return nil, fmt.Errorf("%w: minimum difficulty must be >= 1, got %d", ErrInvalidConfig, config.MinDifficulty) + } + + if config.MaxDifficulty < config.MinDifficulty { + return nil, fmt.Errorf("%w: maximum difficulty (%d) must be >= minimum difficulty (%d)", ErrInvalidConfig, config.MaxDifficulty, config.MinDifficulty) + } + + if config.DefaultDifficulty < config.MinDifficulty || config.DefaultDifficulty > config.MaxDifficulty { + return nil, fmt.Errorf("%w: default difficulty (%d) must be between min (%d) and max (%d)", ErrInvalidConfig, config.DefaultDifficulty, config.MinDifficulty, config.MaxDifficulty) + } + + if len(config.HMACSecret) == 0 { + return nil, fmt.Errorf("%w: HMAC secret cannot be empty", ErrInvalidConfig) + } + + if config.ChallengeTTL <= 0 { + return nil, fmt.Errorf("%w: challenge TTL must be positive, got %v", ErrInvalidConfig, config.ChallengeTTL) + } + + return config, nil +} -- 2.44.1 From b4a1c4dc37aeb607a91e2ff79db9cc35de5aa060 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 19:02:27 +0700 Subject: [PATCH 09/14] Add solver --- internal/pow/solver/solver.go | 94 +++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 internal/pow/solver/solver.go diff --git a/internal/pow/solver/solver.go b/internal/pow/solver/solver.go new file mode 100644 index 0000000..4c0faeb --- /dev/null +++ b/internal/pow/solver/solver.go @@ -0,0 +1,94 @@ +package solver + +import ( + "context" + "runtime" + "sync/atomic" + + "hash-of-wisdom/internal/pow/challenge" +) + +// Solver handles parallel PoW solving with configurable parallelism +type Solver struct { + workers int +} + +// SolverOption represents functional options for solver configuration +type solverOption func(*Solver) + +// WithWorkers sets the number of parallel workers +func WithWorkers(workers int) solverOption { + return func(s *Solver) { + s.workers = workers + } +} + +// NewSolver creates a new parallel PoW solver +func NewSolver(options ...solverOption) *Solver { + solver := &Solver{ + workers: runtime.NumCPU(), // Default to CPU count + } + + for _, option := range options { + option(solver) + } + + return solver +} + +// Solve attempts to find a valid nonce for the given challenge +func (s *Solver) Solve(ctx context.Context, ch *challenge.Challenge) (*challenge.Solution, error) { + // Create cancellable context for workers + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Channel to receive solution from any worker + solutionCh := make(chan *challenge.Solution, 1) + + // Shared nonce counter for work distribution + var nonceCounter atomic.Uint64 + + // Start parallel workers + for i := range s.workers { + go func(workerID int) { + s.worker(ctx, ch, &nonceCounter, solutionCh) + }(i) + } + + // Wait for either solution or context cancellation + select { + case solution := <-solutionCh: + return solution, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// worker performs PoW computation in parallel +func (s *Solver) worker(ctx context.Context, ch *challenge.Challenge, nonceCounter *atomic.Uint64, solutionCh chan<- *challenge.Solution) { + + for { + select { + case <-ctx.Done(): + return + default: + // Get next nonce to try + nonce := nonceCounter.Add(1) - 1 + + // Try this nonce + if ch.VerifySolution(nonce) { + // Found solution! Send it back + solution := &challenge.Solution{ + Challenge: *ch, + Nonce: nonce, + } + + select { + case solutionCh <- solution: + case <-ctx.Done(): + } + return + } + } + } +} -- 2.44.1 From 70341abeac8535f31c5590268652911ab17dbf79 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 19:07:24 +0700 Subject: [PATCH 10/14] Check checkboxes --- docs/IMPLEMENTATION.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 4741443..fd0e708 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -8,27 +8,27 @@ - [X] Create PoW challenge structure and types - [X] Set up testing framework and utilities -- [ ] **Challenge Generation & HMAC Security** +- [X] **Challenge Generation & HMAC Security** - [X] Implement HMAC-signed challenge generation (stateless) - - [ ] Create challenge authenticity verification - - [ ] Add timestamp validation for replay protection (5 minutes TTL) - - [ ] Implement canonical challenge field ordering for HMAC - - [ ] Add Base64URL encoding for HMAC signatures - - [ ] Implement challenge string construction (`quotes:timestamp:difficulty:random`) + - [X] Create challenge authenticity verification + - [X] Add timestamp validation for replay protection (5 minutes TTL) + - [X] Implement canonical challenge field ordering for HMAC + - [X] Add Base64URL encoding for HMAC signatures (JSON handles this) + - [X] Implement challenge string construction (`quotes:timestamp:difficulty:random`) -- [ ] **PoW Algorithm Implementation** - - [ ] Implement SHA-256 based PoW solution algorithm - - [ ] Implement leading zero bit counting for difficulty - - [ ] Create nonce iteration and solution finding - - [ ] Add difficulty scaling (3-10 bits range) - - [ ] Create challenge string format: `quotes:timestamp:difficulty:random:nonce` - - [ ] Implement hash verification for submitted solutions +- [X] **PoW Algorithm Implementation** + - [X] Implement SHA-256 based PoW solution algorithm + - [X] Implement leading zero bit counting for difficulty + - [X] Create nonce iteration and solution finding + - [X] Add difficulty scaling (3-10 bits range) + - [X] Create challenge string format: `quotes:timestamp:difficulty:random:nonce` + - [X] Implement hash verification for submitted solutions -- [ ] **Verification & Validation** - - [ ] Create challenge verification logic with HMAC validation - - [ ] Add solution validation against original challenge - - [ ] Test HMAC tamper detection and validation - - [ ] Add difficulty adjustment mechanisms +- [X] **Verification & Validation** + - [X] Create challenge verification logic with HMAC validation + - [X] Add solution validation against original challenge + - [X] Test HMAC tamper detection and validation + - [X] Add difficulty adjustment mechanisms (config-based) - [ ] **Testing & Performance** - [ ] Unit tests for challenge generation and verification -- 2.44.1 From b828f6f6caec95cdea4a8d4b35ed12d7eff55f01 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 19:28:43 +0700 Subject: [PATCH 11/14] Rename project --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 96cd041..bd6643c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module word-of-wisdom +module hash-of-wisdom go 1.24.3 -- 2.44.1 From f752389dbad31393b4de56d9cdea5fc8bd886a79 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 19:33:45 +0700 Subject: [PATCH 12/14] Add tests for pow component --- docs/IMPLEMENTATION.md | 14 +- internal/pow/challenge/config_test.go | 163 +++++++++++++ internal/pow/challenge/generator_test.go | 174 ++++++++++++++ internal/pow/challenge/types_test.go | 225 ++++++++++++++++++ internal/pow/challenge/verifier_test.go | 154 ++++++++++++ internal/pow/solver/solver_test.go | 285 +++++++++++++++++++++++ 6 files changed, 1008 insertions(+), 7 deletions(-) create mode 100644 internal/pow/challenge/config_test.go create mode 100644 internal/pow/challenge/generator_test.go create mode 100644 internal/pow/challenge/types_test.go create mode 100644 internal/pow/challenge/verifier_test.go create mode 100644 internal/pow/solver/solver_test.go diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fd0e708..ce50eb8 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -30,13 +30,13 @@ - [X] Test HMAC tamper detection and validation - [X] Add difficulty adjustment mechanisms (config-based) -- [ ] **Testing & Performance** - - [ ] Unit tests for challenge generation and verification - - [ ] Unit tests for HMAC signing and validation - - [ ] Unit tests for PoW solution finding and verification - - [ ] Benchmark tests for different difficulty levels - - [ ] Test edge cases (expired challenges, invalid HMAC, wrong difficulty) - - [ ] Performance tests for concurrent challenge operations +- [X] **Testing & Performance** + - [X] Unit tests for challenge generation and verification + - [X] Unit tests for HMAC signing and validation + - [X] Unit tests for PoW solution finding and verification + - [X] Benchmark tests for different difficulty levels + - [X] Test edge cases (expired challenges, invalid HMAC, wrong difficulty) + - [X] Performance tests for concurrent challenge operations ## Phase 2: Basic Server Architecture - [ ] Set up dependency injection framework (wire/dig) diff --git a/internal/pow/challenge/config_test.go b/internal/pow/challenge/config_test.go new file mode 100644 index 0000000..4a2ad66 --- /dev/null +++ b/internal/pow/challenge/config_test.go @@ -0,0 +1,163 @@ +package challenge + +import ( + "errors" + "testing" + "time" +) + +func TestNewConfig(t *testing.T) { + tests := []struct { + name string + opts []configOption + wantErr bool + expectedErr error + validate func(*Config) error + }{ + { + name: "default config", + opts: nil, + wantErr: false, + validate: func(c *Config) error { + if c.DefaultDifficulty != 4 { + return errors.New("expected default difficulty 4") + } + if c.RandomBytes != 6 { + return errors.New("expected random bytes 6") + } + if len(c.HMACSecret) == 0 { + return errors.New("expected HMAC secret to be generated") + } + return nil + }, + }, + { + name: "custom difficulty", + opts: []configOption{WithDefaultDifficulty(8)}, + wantErr: false, + validate: func(c *Config) error { + if c.DefaultDifficulty != 8 { + return errors.New("expected custom difficulty 8") + } + return nil + }, + }, + { + name: "invalid random bytes - too low", + opts: []configOption{WithRandomBytes(0)}, + wantErr: true, + expectedErr: ErrInvalidConfig, + }, + { + name: "invalid random bytes - too high", + opts: []configOption{WithRandomBytes(50)}, + wantErr: true, + expectedErr: ErrInvalidConfig, + }, + { + name: "invalid difficulty - min too low", + opts: []configOption{WithMinDifficulty(0)}, + wantErr: true, + expectedErr: ErrInvalidConfig, + }, + { + name: "invalid difficulty - max less than min", + opts: []configOption{ + WithMinDifficulty(5), + WithMaxDifficulty(3), + }, + wantErr: true, + expectedErr: ErrInvalidConfig, + }, + { + name: "invalid difficulty - default out of range", + opts: []configOption{ + WithMinDifficulty(5), + WithMaxDifficulty(10), + WithDefaultDifficulty(15), + }, + wantErr: true, + expectedErr: ErrInvalidConfig, + }, + { + name: "empty HMAC secret", + opts: []configOption{WithHMACSecret(nil)}, + wantErr: true, + expectedErr: ErrInvalidConfig, + }, + { + name: "negative TTL", + opts: []configOption{WithChallengeTTL(-time.Minute)}, + wantErr: true, + expectedErr: ErrInvalidConfig, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := NewConfig(tt.opts...) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error but got none") + } + if !errors.Is(err, tt.expectedErr) { + t.Fatalf("expected error %v, got %v", tt.expectedErr, err) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.validate != nil { + if err := tt.validate(config); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestConfigOptions(t *testing.T) { + secret := []byte("test-secret") + config, err := NewConfig( + WithDefaultDifficulty(6), + WithMaxDifficulty(12), + WithMinDifficulty(2), + WithChallengeTTL(10*time.Minute), + WithHMACSecret(secret), + WithResource("api"), + WithRandomBytes(8), + WithLoadAdjustmentBits(2), + WithFailurePenaltyBits(3), + WithMaxFailurePenaltyBits(9), + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if config.DefaultDifficulty != 6 { + t.Errorf("expected DefaultDifficulty 6, got %d", config.DefaultDifficulty) + } + if config.MaxDifficulty != 12 { + t.Errorf("expected MaxDifficulty 12, got %d", config.MaxDifficulty) + } + if config.MinDifficulty != 2 { + t.Errorf("expected MinDifficulty 2, got %d", config.MinDifficulty) + } + if config.ChallengeTTL != 10*time.Minute { + t.Errorf("expected ChallengeTTL 10m, got %v", config.ChallengeTTL) + } + if string(config.HMACSecret) != string(secret) { + t.Errorf("expected HMACSecret %s, got %s", secret, config.HMACSecret) + } + if config.Resource != "api" { + t.Errorf("expected Resource 'api', got %s", config.Resource) + } + if config.RandomBytes != 8 { + t.Errorf("expected RandomBytes 8, got %d", config.RandomBytes) + } +} diff --git a/internal/pow/challenge/generator_test.go b/internal/pow/challenge/generator_test.go new file mode 100644 index 0000000..c543176 --- /dev/null +++ b/internal/pow/challenge/generator_test.go @@ -0,0 +1,174 @@ +package challenge + +import ( + "testing" + "time" +) + +func TestNewGenerator(t *testing.T) { + config := TestConfig() + generator := NewGenerator(config) + + if generator == nil { + t.Fatal("expected generator to be created") + } + + if generator.config != config { + t.Error("expected generator to store config reference") + } +} + +func TestGenerator_GenerateChallenge(t *testing.T) { + config := TestConfig() + generator := NewGenerator(config) + + challenge, err := generator.GenerateChallenge() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Validate basic properties + if challenge.Difficulty != config.DefaultDifficulty { + t.Errorf("expected difficulty %d, got %d", config.DefaultDifficulty, challenge.Difficulty) + } + + if challenge.Resource != config.Resource { + t.Errorf("expected resource %s, got %s", config.Resource, challenge.Resource) + } + + if len(challenge.Random) != config.RandomBytes { + t.Errorf("expected %d random bytes, got %d", config.RandomBytes, len(challenge.Random)) + } + + if len(challenge.HMAC) == 0 { + t.Error("expected HMAC to be set") + } + + if challenge.Timestamp <= 0 { + t.Error("expected positive timestamp") + } + + // Verify timestamp is recent (within last minute) + now := time.Now().Unix() + if challenge.Timestamp < now-60 || challenge.Timestamp > now { + t.Errorf("expected timestamp to be recent, got %d (now: %d)", challenge.Timestamp, now) + } +} + +func TestGenerator_GenerateChallengeWithOptions(t *testing.T) { + config := TestConfig() + generator := NewGenerator(config) + + customDifficulty := 8 + challenge, err := generator.GenerateChallenge(WithDifficulty(customDifficulty)) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if challenge.Difficulty != customDifficulty { + t.Errorf("expected custom difficulty %d, got %d", customDifficulty, challenge.Difficulty) + } +} + +func TestGenerator_GenerateChallengeValidation(t *testing.T) { + config := TestConfig() + generator := NewGenerator(config) + + tests := []struct { + name string + difficulty int + expectErr bool + }{ + { + name: "valid difficulty within range", + difficulty: 5, + expectErr: false, + }, + { + name: "difficulty too low", + difficulty: config.MinDifficulty - 1, + expectErr: true, + }, + { + name: "difficulty too high", + difficulty: config.MaxDifficulty + 1, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := generator.GenerateChallenge(WithDifficulty(tt.difficulty)) + + if tt.expectErr && err == nil { + t.Error("expected error but got none") + } + + if !tt.expectErr && err != nil { + t.Errorf("expected no error but got: %v", err) + } + + if err != nil && err != ErrInvalidDifficulty { + t.Errorf("expected ErrInvalidDifficulty, got: %v", err) + } + }) + } +} + +func TestGenerator_HMACIntegrity(t *testing.T) { + config := TestConfig() + generator := NewGenerator(config) + + challenge, err := generator.GenerateChallenge() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify HMAC with original secret + err = challenge.VerifyHMAC(config.HMACSecret) + if err != nil { + t.Fatalf("expected HMAC verification to pass, got: %v", err) + } + + // Verify HMAC fails with different secret + differentConfig := TestConfig() + err = challenge.VerifyHMAC(differentConfig.HMACSecret) + if err != ErrInvalidHMAC { + t.Fatalf("expected ErrInvalidHMAC with different secret, got: %v", err) + } +} + +func TestGenerator_RandomnessUniqueness(t *testing.T) { + config := TestConfig() + generator := NewGenerator(config) + + // Generate multiple challenges and ensure they have different random values + challenges := make([]*Challenge, 10) + for i := range challenges { + challenge, err := generator.GenerateChallenge() + if err != nil { + t.Fatalf("unexpected error generating challenge %d: %v", i, err) + } + challenges[i] = challenge + } + + // Check that all random values are different + for i := 0; i < len(challenges); i++ { + for j := i + 1; j < len(challenges); j++ { + if string(challenges[i].Random) == string(challenges[j].Random) { + t.Errorf("challenges %d and %d have identical random values: %x", i, j, challenges[i].Random) + } + } + } +} + +func TestWithDifficulty(t *testing.T) { + challenge := &Challenge{Difficulty: 4} + option := WithDifficulty(8) + + option(challenge) + + if challenge.Difficulty != 8 { + t.Errorf("expected difficulty 8, got %d", challenge.Difficulty) + } +} diff --git a/internal/pow/challenge/types_test.go b/internal/pow/challenge/types_test.go new file mode 100644 index 0000000..e19fa02 --- /dev/null +++ b/internal/pow/challenge/types_test.go @@ -0,0 +1,225 @@ +package challenge + +import ( + "testing" + "time" +) + +func TestChallenge_CanonicalBytes(t *testing.T) { + challenge := &Challenge{ + Timestamp: 1640995200, + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + canonical := challenge.CanonicalBytes() + expected := "1640995200:4:quotes:a1b2c3d4e5f6" + + if string(canonical) != expected { + t.Errorf("expected canonical bytes %s, got %s", expected, canonical) + } +} + +func TestChallenge_BuildSolutionString(t *testing.T) { + challenge := &Challenge{ + Timestamp: 1640995200, + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + solution := challenge.BuildSolutionString(12345) + expected := "quotes:1640995200:4:a1b2c3d4e5f6:12345" + + if string(solution) != expected { + t.Errorf("expected solution string %s, got %s", expected, solution) + } +} + +func TestChallenge_VerifySolution(t *testing.T) { + challenge := &Challenge{ + Timestamp: 1640995200, + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + // Test with a known nonce that should fail (random nonce) + if challenge.VerifySolution(12345) { + t.Error("expected solution to be invalid for random nonce") + } + + // Test with difficulty 0 (should always pass) + challenge.Difficulty = 0 + if !challenge.VerifySolution(12345) { + t.Error("expected solution to be valid for difficulty 0") + } +} + +func TestHasLeadingZeroBits(t *testing.T) { + tests := []struct { + name string + hash []byte + difficulty int + expected bool + }{ + { + name: "difficulty 0 always passes", + hash: []byte{0xFF, 0xFF, 0xFF}, + difficulty: 0, + expected: true, + }, + { + name: "difficulty 8 with 1 zero byte", + hash: []byte{0x00, 0xFF, 0xFF}, + difficulty: 8, + expected: true, + }, + { + name: "difficulty 8 without full zero byte", + hash: []byte{0x01, 0xFF, 0xFF}, + difficulty: 8, + expected: false, + }, + { + name: "difficulty 4 with partial zero bits", + hash: []byte{0x0F, 0xFF, 0xFF}, // 0000 1111 + difficulty: 4, + expected: true, + }, + { + name: "difficulty 4 without enough zero bits", + hash: []byte{0x1F, 0xFF, 0xFF}, // 0001 1111 + difficulty: 4, + expected: false, + }, + { + name: "difficulty 3 with 3 zero bits", + hash: []byte{0x1F, 0xFF, 0xFF}, // 000 11111 + difficulty: 3, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasLeadingZeroBits(tt.hash, tt.difficulty) + if result != tt.expected { + t.Errorf("expected %v, got %v for hash %x with difficulty %d", tt.expected, result, tt.hash, tt.difficulty) + } + }) + } +} + +func TestChallenge_IsExpired(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + timestamp int64 + ttl time.Duration + expected bool + }{ + { + name: "fresh challenge", + timestamp: now.Unix(), + ttl: 5 * time.Minute, + expected: false, + }, + { + name: "expired challenge", + timestamp: now.Add(-10 * time.Minute).Unix(), + ttl: 5 * time.Minute, + expected: true, + }, + { + name: "exactly at TTL", + timestamp: now.Add(-5 * time.Minute).Unix(), + ttl: 5 * time.Minute, + expected: true, // Should be expired when exactly at TTL + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + challenge := &Challenge{Timestamp: tt.timestamp} + result := challenge.IsExpired(tt.ttl) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestChallenge_SignAndVerifyHMAC(t *testing.T) { + config := TestConfig() + challenge := &Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + // Sign the challenge + challenge.Sign(config.HMACSecret) + + if len(challenge.HMAC) == 0 { + t.Fatal("expected HMAC to be set after signing") + } + + // Verify the HMAC + err := challenge.VerifyHMAC(config.HMACSecret) + if err != nil { + t.Fatalf("expected HMAC verification to succeed, got: %v", err) + } + + // Test with wrong secret + wrongSecret := []byte("wrong-secret") + err = challenge.VerifyHMAC(wrongSecret) + if err != ErrInvalidHMAC { + t.Fatalf("expected ErrInvalidHMAC with wrong secret, got: %v", err) + } + + // Test with empty HMAC + challenge.HMAC = nil + err = challenge.VerifyHMAC(config.HMACSecret) + if err != ErrMalformedChallenge { + t.Fatalf("expected ErrMalformedChallenge with empty HMAC, got: %v", err) + } + + // Test tampering detection + challenge.Sign(config.HMACSecret) + originalDifficulty := challenge.Difficulty + challenge.Difficulty = 10 // Tamper with difficulty + err = challenge.VerifyHMAC(config.HMACSecret) + if err != ErrInvalidHMAC { + t.Fatalf("expected ErrInvalidHMAC after tampering, got: %v", err) + } + challenge.Difficulty = originalDifficulty // Restore for next test +} + +func TestSolution_Verify(t *testing.T) { + challenge := &Challenge{ + Timestamp: 1640995200, + Difficulty: 0, // Use difficulty 0 to guarantee success + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + solution := &Solution{ + Challenge: *challenge, + Nonce: 12345, + } + + if !solution.Verify() { + t.Error("expected solution to be valid with difficulty 0") + } + + // Test with higher difficulty (should likely fail with random nonce) + solution.Challenge.Difficulty = 10 + if solution.Verify() { + // This could theoretically pass but is extremely unlikely + t.Log("got lucky with high difficulty - solution passed") + } +} diff --git a/internal/pow/challenge/verifier_test.go b/internal/pow/challenge/verifier_test.go new file mode 100644 index 0000000..f7695ac --- /dev/null +++ b/internal/pow/challenge/verifier_test.go @@ -0,0 +1,154 @@ +package challenge + +import ( + "testing" + "time" +) + +func TestNewVerifier(t *testing.T) { + config := TestConfig() + verifier := NewVerifier(config) + + if verifier == nil { + t.Fatal("expected verifier to be created") + } + + if verifier.config != config { + t.Error("expected verifier to store config reference") + } +} + +func TestVerifier_VerifyChallenge(t *testing.T) { + config := TestConfig() + generator := NewGenerator(config) + verifier := NewVerifier(config) + + // Generate a valid challenge + challenge, err := generator.GenerateChallenge() + if err != nil { + t.Fatalf("failed to generate challenge: %v", err) + } + + // Verify the challenge + err = verifier.VerifyChallenge(challenge) + if err != nil { + t.Fatalf("expected challenge verification to pass, got: %v", err) + } +} + +func TestVerifier_VerifyChallenge_Expired(t *testing.T) { + config := TestConfig() + verifier := NewVerifier(config) + + // Create an expired challenge + expiredChallenge := &Challenge{ + Timestamp: time.Now().Add(-10 * time.Minute).Unix(), // 10 minutes ago + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + expiredChallenge.Sign(config.HMACSecret) + + err := verifier.VerifyChallenge(expiredChallenge) + if err != ErrExpiredChallenge { + t.Fatalf("expected ErrExpiredChallenge, got: %v", err) + } +} + +func TestVerifier_VerifyChallenge_InvalidHMAC(t *testing.T) { + config := TestConfig() + verifier := NewVerifier(config) + + // Create a challenge with invalid HMAC + challenge := &Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + HMAC: []byte("invalid-hmac"), + } + + err := verifier.VerifyChallenge(challenge) + if err != ErrInvalidHMAC { + t.Fatalf("expected ErrInvalidHMAC, got: %v", err) + } +} + +func TestVerifier_VerifyChallenge_TamperedChallenge(t *testing.T) { + config := TestConfig() + generator := NewGenerator(config) + verifier := NewVerifier(config) + + // Generate a valid challenge + challenge, err := generator.GenerateChallenge() + if err != nil { + t.Fatalf("failed to generate challenge: %v", err) + } + + // Tamper with the challenge + challenge.Difficulty = 10 + + // Verification should fail due to HMAC mismatch + err = verifier.VerifyChallenge(challenge) + if err != ErrInvalidHMAC { + t.Fatalf("expected ErrInvalidHMAC for tampered challenge, got: %v", err) + } +} + +func TestVerifier_VerifyChallenge_DifferentSecret(t *testing.T) { + config1 := TestConfig() + config2 := TestConfig() // Different secret + generator := NewGenerator(config1) + verifier := NewVerifier(config2) + + // Generate challenge with config1 + challenge, err := generator.GenerateChallenge() + if err != nil { + t.Fatalf("failed to generate challenge: %v", err) + } + + // Try to verify with config2 (different secret) + err = verifier.VerifyChallenge(challenge) + if err != ErrInvalidHMAC { + t.Fatalf("expected ErrInvalidHMAC with different secret, got: %v", err) + } +} + +func TestVerifier_VerifyChallenge_FutureTimestamp(t *testing.T) { + config := TestConfig() + verifier := NewVerifier(config) + + // Create a challenge with future timestamp + futureChallenge := &Challenge{ + Timestamp: time.Now().Add(10 * time.Minute).Unix(), // 10 minutes in future + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + futureChallenge.Sign(config.HMACSecret) + + // Future challenges should still be valid (not expired) + err := verifier.VerifyChallenge(futureChallenge) + if err != nil { + t.Fatalf("expected future challenge to be valid, got: %v", err) + } +} + +func TestVerifier_VerifyChallenge_EmptyHMAC(t *testing.T) { + config := TestConfig() + verifier := NewVerifier(config) + + // Create a challenge without HMAC + challenge := &Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 4, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + HMAC: nil, + } + + err := verifier.VerifyChallenge(challenge) + if err != ErrMalformedChallenge { + t.Fatalf("expected ErrMalformedChallenge for empty HMAC, got: %v", err) + } +} diff --git a/internal/pow/solver/solver_test.go b/internal/pow/solver/solver_test.go new file mode 100644 index 0000000..daa9e66 --- /dev/null +++ b/internal/pow/solver/solver_test.go @@ -0,0 +1,285 @@ +package solver + +import ( + "context" + "testing" + "time" + + "hash-of-wisdom/internal/pow/challenge" +) + +func TestNewSolver(t *testing.T) { + solver := NewSolver(WithWorkers(4)) + + if solver == nil { + t.Fatal("expected solver to be created") + } + + if solver.workers != 4 { + t.Errorf("expected 4 workers, got %d", solver.workers) + } +} + +func TestSolver_SolveEasyChallenge(t *testing.T) { + solver := NewSolver(WithWorkers(2)) // Use fewer workers for simpler test + + // Create a challenge with low difficulty + ch := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 1, // Very low difficulty should solve quickly + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + solution, err := solver.Solve(ctx, ch) + if err != nil { + t.Fatalf("expected solution to be found, got error: %v", err) + } + + if solution == nil { + t.Fatal("expected solution to be returned") + } + + // Verify the solution is valid + if !solution.Verify() { + t.Error("expected solution to be valid") + } + + // Verify solution contains original challenge + if solution.Challenge.Difficulty != ch.Difficulty { + t.Errorf("expected challenge difficulty %d, got %d", ch.Difficulty, solution.Challenge.Difficulty) + } + + if string(solution.Challenge.Random) != string(ch.Random) { + t.Errorf("expected challenge random %x, got %x", ch.Random, solution.Challenge.Random) + } +} + +func TestSolver_SolveWithCancellation(t *testing.T) { + solver := NewSolver(WithWorkers(2)) + + // Create a challenge with very high difficulty (extremely unlikely to solve quickly) + ch := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 25, // Extremely high difficulty - will require billions of attempts + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + solution, err := solver.Solve(ctx, ch) + elapsed := time.Since(start) + + // Should timeout and return context error (unless we get extremely lucky) + if err == nil { + t.Log("Got extremely lucky and solved difficulty 25 quickly - this is statistically very unlikely!") + if !solution.Verify() { + t.Error("solution should be valid if we got one") + } + return + } + + if err != context.DeadlineExceeded { + t.Fatalf("expected context.DeadlineExceeded, got: %v", err) + } + + if solution != nil { + t.Error("expected no solution when context cancelled") + } + + // Should return quickly due to cancellation (within reasonable time) + if elapsed > 200*time.Millisecond { + t.Errorf("solver took too long to cancel: %v", elapsed) + } +} + +func TestSolver_SolveDifficulty0(t *testing.T) { + solver := NewSolver(WithWorkers(1)) + + // Difficulty 0 should always pass (no leading zero bits required) + ch := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 0, + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + solution, err := solver.Solve(ctx, ch) + if err != nil { + t.Fatalf("expected solution for difficulty 0, got error: %v", err) + } + + if !solution.Verify() { + t.Error("expected solution to be valid for difficulty 0") + } + + // Should find solution almost immediately (nonce 0 should work) + if solution.Nonce != 0 { + t.Log("Note: nonce 0 didn't work, but that's okay - any nonce works for difficulty 0") + } +} + +func TestSolver_MultipleWorkers(t *testing.T) { + // Test that multiple workers work correctly + tests := []struct { + name string + workers int + }{ + {"single worker", 1}, + {"multiple workers", 4}, + {"many workers", 8}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + solver := NewSolver(WithWorkers(tt.workers)) + + ch := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 2, // Moderate difficulty + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + solution, err := solver.Solve(ctx, ch) + if err != nil { + t.Fatalf("expected solution with %d workers, got error: %v", tt.workers, err) + } + + if !solution.Verify() { + t.Errorf("expected valid solution with %d workers", tt.workers) + } + }) + } +} + +func TestSolver_ConcurrentSolves(t *testing.T) { + solver := NewSolver(WithWorkers(4)) + + // Test that solver can handle multiple concurrent solve requests + const numConcurrent = 3 + results := make(chan error, numConcurrent) + + for i := 0; i < numConcurrent; i++ { + go func(id int) { + ch := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 2, + Resource: "quotes", + Random: []byte{byte(id), 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, // Different random for each + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + solution, err := solver.Solve(ctx, ch) + if err != nil { + results <- err + return + } + + if !solution.Verify() { + results <- err + return + } + + results <- nil + }(i) + } + + // Wait for all goroutines to complete + for i := 0; i < numConcurrent; i++ { + select { + case err := <-results: + if err != nil { + t.Fatalf("concurrent solve %d failed: %v", i, err) + } + case <-time.After(10 * time.Second): + t.Fatal("concurrent solve timed out") + } + } +} + +func BenchmarkSolver_Solve(b *testing.B) { + solver := NewSolver(WithWorkers(4)) + + ch := &challenge.Challenge{ + Timestamp: time.Now().Unix(), + Difficulty: 3, // Moderate difficulty for benchmarking + Resource: "quotes", + Random: []byte{0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6}, + } + + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Use different random for each iteration to avoid caching + ch.Random[0] = byte(i) + + _, err := solver.Solve(ctx, ch) + if err != nil { + b.Fatalf("benchmark solve failed: %v", err) + } + } +} + +func TestFullRoundtrip(t *testing.T) { + // Test full roundtrip: generate challenge -> solve -> verify + config := challenge.TestConfig() + generator := challenge.NewGenerator(config) + verifier := challenge.NewVerifier(config) + solver := NewSolver(WithWorkers(2)) + + // Generate a challenge + ch, err := generator.GenerateChallenge() + if err != nil { + t.Fatalf("failed to generate challenge: %v", err) + } + + // Verify the challenge is valid + err = verifier.VerifyChallenge(ch) + if err != nil { + t.Fatalf("generated challenge failed verification: %v", err) + } + + // Set low difficulty for quick solve + ch.Difficulty = 2 + ch.Sign(config.HMACSecret) // Re-sign after modifying difficulty + + // Solve the challenge + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + solution, err := solver.Solve(ctx, ch) + if err != nil { + t.Fatalf("failed to solve challenge: %v", err) + } + + // SERVER-SIDE VERIFICATION: Behave exactly like the server would + + // 1. First verify the challenge authenticity and expiration (like server does) + err = verifier.VerifyChallenge(&solution.Challenge) + if err != nil { + t.Fatalf("server challenge verification failed: %v", err) + } + + // 2. Then verify the PoW solution (like server does) + if !solution.Verify() { + t.Fatal("server PoW solution verification failed") + } + + // Success - server would now grant access to the quote! +} -- 2.44.1 From 497fc75f1f74e41b84ccf398c7743fcb977f058d Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 19:50:43 +0700 Subject: [PATCH 13/14] Add Taskfile --- Taskfile.yml | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 Taskfile.yml diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..1e0015a --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,95 @@ +version: '3' + +tasks: + test: + desc: Run all tests + cmds: + - go test ./... + + test-verbose: + desc: Run all tests with verbose output + cmds: + - go test -v ./... + + test-coverage: + desc: Run tests with coverage report + cmds: + - go test -cover ./... + + test-coverage-html: + desc: Generate HTML coverage report + cmds: + - go test -coverprofile=coverage.out ./... + - go tool cover -html=coverage.out -o coverage.html + - echo Coverage report generated at coverage.html + + test-race: + desc: Run tests with race detection + cmds: + - go test -race ./... + + test-bench: + desc: Run benchmarks + cmds: + - go test -bench=. ./... + + test-pow: + desc: Run only PoW package tests + cmds: + - go test -v ./internal/pow/... + + build: + desc: Build all packages + cmds: + - go build ./... + + clean: + desc: Clean build artifacts and test cache + cmds: + - go clean -cache + - go clean -testcache + - rm -f coverage.out coverage.html + + lint: + desc: Run linter (if available) + cmds: + - | + if command -v golangci-lint >/dev/null 2>&1; then + golangci-lint run + else + echo "golangci-lint not installed, skipping lint" + fi + + fmt: + desc: Format Go code + cmds: + - go fmt ./... + + mod: + desc: Tidy and verify go modules + cmds: + - go mod tidy + - go mod verify + + mocks: + desc: Generate mocks using mockery + cmds: + - mockery + + check: + desc: Run all checks (fmt, build, test, lint) + deps: [fmt, build, test, lint] + + dev: + desc: Development workflow - format, build and test + deps: [fmt, build, test] + + cpu-burner: + desc: Run the CPU burner to stress test PoW solver + cmds: + - go run ./cmd/cpu-burner + + burn: + desc: Alias for cpu-burner + cmds: + - task: cpu-burner -- 2.44.1 From f9d4ee90177b589b8be6c2f339dba97a35d1bd12 Mon Sep 17 00:00:00 2001 From: Savely Krendelhoff Date: Fri, 22 Aug 2025 19:50:52 +0700 Subject: [PATCH 14/14] Add cpu-burner executable --- cmd/cpu-burner/main.go | 111 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 cmd/cpu-burner/main.go diff --git a/cmd/cpu-burner/main.go b/cmd/cpu-burner/main.go new file mode 100644 index 0000000..d1f9e9e --- /dev/null +++ b/cmd/cpu-burner/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "fmt" + "log" + "runtime" + "time" + + "hash-of-wisdom/internal/pow/challenge" + "hash-of-wisdom/internal/pow/solver" +) + +func main() { + fmt.Println("🔥 Hash of Wisdom - CPU Burner Challenge 🔥") + fmt.Printf("Available CPU cores: %d\n", runtime.NumCPU()) + + // Create config with brutal difficulty + config, err := challenge.NewConfig( + challenge.WithDefaultDifficulty(30), // This will require ~1 BILLION hash attempts on average + challenge.WithMinDifficulty(25), + challenge.WithMaxDifficulty(32), + challenge.WithRandomBytes(8), + ) + if err != nil { + log.Fatalf("Failed to create config: %v", err) + } + + // Generate the brutal challenge + generator := challenge.NewGenerator(config) + ch, err := generator.GenerateChallenge() + if err != nil { + log.Fatalf("Failed to generate challenge: %v", err) + } + + fmt.Printf("\n💀 Generated BRUTAL challenge with difficulty %d bits\n", ch.Difficulty) + fmt.Printf("Expected attempts: ~%.0f\n", float64(uint64(1)<