Compare commits
5 commits
764ac2e2a7
...
750223b2cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
750223b2cc | ||
|
|
0fbdfe1b83 | ||
|
|
f14132aa4b | ||
|
|
ae2592436c | ||
|
|
ba0df856fe |
11
.mockery.yaml
Normal file
11
.mockery.yaml
Normal 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
9
.pre-commit-config.yaml
Normal 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]
|
||||||
|
|
@ -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
2
go.mod
|
|
@ -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
2
go.sum
Normal 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
97
internal/pow/generator.go
Normal 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)
|
||||||
|
}
|
||||||
65
internal/pow/test_utils.go
Normal file
65
internal/pow/test_utils.go
Normal 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
53
internal/pow/types.go
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in a new issue