Compare commits

...

5 commits

8 changed files with 243 additions and 7 deletions

11
.mockery.yaml Normal file
View file

@ -0,0 +1,11 @@
with-expecter: true
dir: "{{.InterfaceDir}}/mocks"
mockname: "Mock{{.InterfaceName}}"
outpkg: "{{.PackageName}}"
filename: "mock_{{.InterfaceName | snakecase}}.go"
packages:
github.com/word-of-wisdom/internal/pow:
interfaces:
Generator:
Verifier:
Solver:

9
.pre-commit-config.yaml Normal file
View file

@ -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]

View file

@ -97,22 +97,20 @@ All protocol messages use a binary format with the following structure:
- **Format**: - **Format**:
```json ```json
{ {
"id": "challenge_unique_id",
"timestamp": 1640995200, "timestamp": 1640995200,
"difficulty": 4, "difficulty": 4,
"resource": "192.168.1.100:8080", "resource": "quotes",
"random": "a1b2c3d4e5f6", "random": "a1b2c3d4e5f6",
"hmac": "base64url_encoded_signature" "hmac": "base64url_encoded_signature"
} }
``` ```
**Field Descriptions**: **Field Descriptions**:
- **id**: Unique identifier for this challenge
- **timestamp**: Unix timestamp when challenge was created - **timestamp**: Unix timestamp when challenge was created
- **difficulty**: Number of leading zero bits required in solution hash - **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 - **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**: **Security Notes**:
- Server is **stateless**: no need to store challenges locally - 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 ```json
{ {
"challenge": { "challenge": {
"id": "challenge_unique_id",
"timestamp": 1640995200, "timestamp": 1640995200,
"difficulty": 4, "difficulty": 4,
"resource": "192.168.1.100:8080", "resource": "quotes",
"random": "a1b2c3d4e5f6", "random": "a1b2c3d4e5f6",
"hmac": "base64url_encoded_signature" "hmac": "base64url_encoded_signature"
}, },

2
go.mod
View file

@ -1,3 +1,5 @@
module word-of-wisdom module word-of-wisdom
go 1.24.3 go 1.24.3
require github.com/stretchr/testify v1.10.0 // indirect

2
go.sum Normal file
View file

@ -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=

97
internal/pow/generator.go Normal file
View file

@ -0,0 +1,97 @@
package pow
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"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 only after validation
challenge.Random = g.generateRandom(12) // 12 hex chars = 6 bytes
// Sign the challenge with HMAC
signature, err := g.signChallenge(challenge)
if err != nil {
return nil, fmt.Errorf("failed to sign challenge: %w", err)
}
challenge.HMAC = signature
return challenge, nil
}
// signChallenge creates HMAC signature for the challenge
func (g *Generator) signChallenge(challenge *Challenge) (string, error) {
// Create canonical string for signing (excluding HMAC field)
canonical := g.canonicalChallengeString(challenge)
// Create HMAC
h := hmac.New(sha256.New, g.config.HMACSecret)
h.Write([]byte(canonical))
signature := h.Sum(nil)
// Return base64url encoded signature
return base64.RawURLEncoding.EncodeToString(signature), nil
}
// canonicalChallengeString creates a consistent string representation for HMAC
func (g *Generator) canonicalChallengeString(challenge *Challenge) string {
// Use consistent field ordering: timestamp:difficulty:resource:random
return fmt.Sprintf("%d:%d:%s:%s",
challenge.Timestamp,
challenge.Difficulty,
challenge.Resource,
challenge.Random,
)
}
// generateRandom creates cryptographic random hex string
func (g *Generator) generateRandom(length int) string {
bytes := make([]byte, length/2)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}

View file

@ -0,0 +1,65 @@
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)
return &Config{
DefaultDifficulty: 4,
MaxDifficulty: 10,
MinDifficulty: 3,
ChallengeTTL: 5 * time.Minute,
HMACSecret: secret,
Resource: "quotes",
LoadAdjustmentBits: 1,
FailurePenaltyBits: 2,
MaxFailurePenaltyBits: 6,
}
}
// TestChallenge returns a valid challenge for testing
func TestChallenge() *Challenge {
return &Challenge{
Timestamp: time.Now().Unix(),
Difficulty: 4,
Resource: "quotes",
Random: "a1b2c3d4e5f6",
HMAC: "", // 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
}

53
internal/pow/types.go Normal file
View file

@ -0,0 +1,53 @@
package pow
import (
"errors"
"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")
)
// 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 string `json:"random"`
HMAC string `json:"hmac"`
}
// Solution represents a client's solution to a PoW challenge
type Solution struct {
Challenge Challenge `json:"challenge"`
Nonce string `json:"nonce"`
}
// ChallengeRequest represents a request for a new challenge
type ChallengeRequest struct{}
// SolutionRequest represents a client's submission of a solved challenge
type SolutionRequest struct {
Challenge Challenge `json:"challenge"`
Nonce string `json:"nonce"`
}
// 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")
LoadAdjustmentBits int // Extra difficulty bits when server under load
FailurePenaltyBits int // Extra difficulty bits per failure group
MaxFailurePenaltyBits int // Maximum failure penalty bits
}