Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
180
pca-platform/heuristic-service/internal/stepup/pow.go
Normal file
180
pca-platform/heuristic-service/internal/stepup/pow.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package stepup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
// PoWService handles Proof-of-Work challenges
|
||||
type PoWService struct {
|
||||
config *config.PoWConfig
|
||||
challenges map[string]*PoWChallenge
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// PoWChallenge represents a Proof-of-Work challenge
|
||||
type PoWChallenge struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Solved bool `json:"solved"`
|
||||
}
|
||||
|
||||
// PoWChallengeResponse is sent to the client
|
||||
type PoWChallengeResponse struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
MaxTimeMs int `json:"max_time_ms"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// PoWVerifyRequest for verifying a solved challenge
|
||||
type PoWVerifyRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
}
|
||||
|
||||
// NewPoWService creates a new Proof-of-Work service
|
||||
func NewPoWService(cfg *config.PoWConfig) *PoWService {
|
||||
return &PoWService{
|
||||
config: cfg,
|
||||
challenges: make(map[string]*PoWChallenge),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateChallenge generates a new PoW challenge
|
||||
func (s *PoWService) CreateChallenge(sessionID string) (*PoWChallengeResponse, error) {
|
||||
// Generate random challenge
|
||||
challengeBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(challengeBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challengeStr := hex.EncodeToString(challengeBytes)
|
||||
|
||||
// Generate challenge ID
|
||||
idBytes := make([]byte, 8)
|
||||
rand.Read(idBytes)
|
||||
challengeID := hex.EncodeToString(idBytes)
|
||||
|
||||
// Create challenge
|
||||
challenge := &PoWChallenge{
|
||||
ID: challengeID,
|
||||
SessionID: sessionID,
|
||||
Challenge: challengeStr,
|
||||
Difficulty: s.config.Difficulty,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Duration(s.config.MaxDurationMs*2) * time.Millisecond),
|
||||
Solved: false,
|
||||
}
|
||||
|
||||
// Store challenge
|
||||
s.mu.Lock()
|
||||
s.challenges[challengeID] = challenge
|
||||
s.mu.Unlock()
|
||||
|
||||
// Build response
|
||||
prefix := strings.Repeat("0", s.config.Difficulty)
|
||||
response := &PoWChallengeResponse{
|
||||
ChallengeID: challengeID,
|
||||
Challenge: challengeStr,
|
||||
Difficulty: s.config.Difficulty,
|
||||
MaxTimeMs: s.config.MaxDurationMs,
|
||||
Hint: fmt.Sprintf("Find nonce where SHA256(challenge + nonce) starts with '%s'", prefix),
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// VerifyChallenge verifies a PoW solution
|
||||
func (s *PoWService) VerifyChallenge(req *PoWVerifyRequest) (bool, error) {
|
||||
s.mu.RLock()
|
||||
challenge, exists := s.challenges[req.ChallengeID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(challenge.ExpiresAt) {
|
||||
s.mu.Lock()
|
||||
delete(s.challenges, req.ChallengeID)
|
||||
s.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check session match
|
||||
if challenge.SessionID != req.SessionID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check challenge string match
|
||||
if challenge.Challenge != req.Challenge {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Verify the proof of work
|
||||
input := fmt.Sprintf("%s%d", req.Challenge, req.Nonce)
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
// Check if hash has required number of leading zeros
|
||||
prefix := strings.Repeat("0", challenge.Difficulty)
|
||||
if !strings.HasPrefix(hashHex, prefix) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Mark as solved
|
||||
s.mu.Lock()
|
||||
challenge.Solved = true
|
||||
s.mu.Unlock()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// VerifyProof is a standalone verification without stored challenge
|
||||
// Useful for quick verification
|
||||
func (s *PoWService) VerifyProof(challenge string, nonce int64, difficulty int) bool {
|
||||
input := fmt.Sprintf("%s%d", challenge, nonce)
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
prefix := strings.Repeat("0", difficulty)
|
||||
return strings.HasPrefix(hashHex, prefix)
|
||||
}
|
||||
|
||||
// CleanupExpiredChallenges removes expired challenges
|
||||
func (s *PoWService) CleanupExpiredChallenges() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, challenge := range s.challenges {
|
||||
if now.After(challenge.ExpiresAt) {
|
||||
delete(s.challenges, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether PoW is enabled
|
||||
func (s *PoWService) IsEnabled() bool {
|
||||
return s.config.Enabled
|
||||
}
|
||||
|
||||
// GetDifficulty returns configured difficulty
|
||||
func (s *PoWService) GetDifficulty() int {
|
||||
return s.config.Difficulty
|
||||
}
|
||||
235
pca-platform/heuristic-service/internal/stepup/pow_test.go
Normal file
235
pca-platform/heuristic-service/internal/stepup/pow_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package stepup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
func TestNewPoWService(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
if service == nil {
|
||||
t.Fatal("Expected non-nil service")
|
||||
}
|
||||
if !service.IsEnabled() {
|
||||
t.Error("Expected service to be enabled")
|
||||
}
|
||||
if service.GetDifficulty() != 4 {
|
||||
t.Errorf("Expected difficulty 4, got %d", service.GetDifficulty())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChallenge(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
response, err := service.CreateChallenge("test-session")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if response == nil {
|
||||
t.Fatal("Expected non-nil response")
|
||||
}
|
||||
if response.Challenge == "" {
|
||||
t.Error("Expected non-empty challenge")
|
||||
}
|
||||
if response.ChallengeID == "" {
|
||||
t.Error("Expected non-empty challenge ID")
|
||||
}
|
||||
if response.Difficulty != 4 {
|
||||
t.Errorf("Expected difficulty 4, got %d", response.Difficulty)
|
||||
}
|
||||
if response.MaxTimeMs != 5000 {
|
||||
t.Errorf("Expected max time 5000, got %d", response.MaxTimeMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProof_Valid(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2, // Low difficulty for fast testing
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Find a valid nonce for a known challenge
|
||||
challenge := "test-challenge-123"
|
||||
var validNonce int64 = -1
|
||||
|
||||
// Brute force to find valid nonce (with low difficulty)
|
||||
for nonce := int64(0); nonce < 10000; nonce++ {
|
||||
if service.VerifyProof(challenge, nonce, 2) {
|
||||
validNonce = nonce
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if validNonce == -1 {
|
||||
t.Skip("Could not find valid nonce in reasonable time")
|
||||
}
|
||||
|
||||
// Verify the found nonce
|
||||
if !service.VerifyProof(challenge, validNonce, 2) {
|
||||
t.Errorf("Expected valid proof for nonce %d", validNonce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProof_Invalid(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Nonce 0 is very unlikely to be valid for difficulty 4
|
||||
valid := service.VerifyProof("random-challenge", 0, 4)
|
||||
|
||||
if valid {
|
||||
t.Error("Expected invalid proof for nonce 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChallenge_ValidFlow(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2,
|
||||
MaxDurationMs: 10000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Create challenge
|
||||
response, err := service.CreateChallenge("test-session")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create challenge: %v", err)
|
||||
}
|
||||
|
||||
// Find valid nonce
|
||||
var validNonce int64 = -1
|
||||
for nonce := int64(0); nonce < 100000; nonce++ {
|
||||
if service.VerifyProof(response.Challenge, nonce, 2) {
|
||||
validNonce = nonce
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if validNonce == -1 {
|
||||
t.Skip("Could not find valid nonce")
|
||||
}
|
||||
|
||||
// Verify challenge
|
||||
req := &PoWVerifyRequest{
|
||||
SessionID: "test-session",
|
||||
ChallengeID: response.ChallengeID,
|
||||
Challenge: response.Challenge,
|
||||
Nonce: validNonce,
|
||||
}
|
||||
|
||||
verified, err := service.VerifyChallenge(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Verification error: %v", err)
|
||||
}
|
||||
if !verified {
|
||||
t.Error("Expected verification to succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChallenge_WrongSession(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Create challenge for session A
|
||||
response, _ := service.CreateChallenge("session-a")
|
||||
|
||||
// Try to verify with session B
|
||||
req := &PoWVerifyRequest{
|
||||
SessionID: "session-b",
|
||||
ChallengeID: response.ChallengeID,
|
||||
Challenge: response.Challenge,
|
||||
Nonce: 0,
|
||||
}
|
||||
|
||||
verified, _ := service.VerifyChallenge(req)
|
||||
if verified {
|
||||
t.Error("Expected verification to fail for wrong session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChallenge_NonexistentChallenge(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
req := &PoWVerifyRequest{
|
||||
SessionID: "test-session",
|
||||
ChallengeID: "nonexistent-challenge",
|
||||
Challenge: "test",
|
||||
Nonce: 0,
|
||||
}
|
||||
|
||||
verified, _ := service.VerifyChallenge(req)
|
||||
if verified {
|
||||
t.Error("Expected verification to fail for nonexistent challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupExpiredChallenges(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: true,
|
||||
Difficulty: 2,
|
||||
MaxDurationMs: 1, // Very short for testing
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
// Create challenge
|
||||
service.CreateChallenge("test-session")
|
||||
|
||||
if len(service.challenges) != 1 {
|
||||
t.Errorf("Expected 1 challenge, got %d", len(service.challenges))
|
||||
}
|
||||
|
||||
// Wait for expiration
|
||||
// Note: In real test, we'd mock time or set ExpiresAt in the past
|
||||
|
||||
// For now, just verify cleanup doesn't crash
|
||||
service.CleanupExpiredChallenges()
|
||||
}
|
||||
|
||||
func TestIsEnabled(t *testing.T) {
|
||||
cfg := &config.PoWConfig{
|
||||
Enabled: false,
|
||||
Difficulty: 4,
|
||||
MaxDurationMs: 5000,
|
||||
}
|
||||
|
||||
service := NewPoWService(cfg)
|
||||
|
||||
if service.IsEnabled() {
|
||||
t.Error("Expected service to be disabled")
|
||||
}
|
||||
}
|
||||
172
pca-platform/heuristic-service/internal/stepup/webauthn.go
Normal file
172
pca-platform/heuristic-service/internal/stepup/webauthn.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package stepup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
|
||||
)
|
||||
|
||||
// WebAuthnService handles WebAuthn challenges and verification
|
||||
type WebAuthnService struct {
|
||||
config *config.WebAuthnConfig
|
||||
challenges map[string]*Challenge
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Challenge represents a WebAuthn challenge
|
||||
type Challenge struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// ChallengeRequest is the client-side challenge request format
|
||||
type ChallengeRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// ChallengeResponse is the WebAuthn public key request options
|
||||
type ChallengeResponse struct {
|
||||
PublicKey PublicKeyCredentialRequestOptions `json:"publicKey"`
|
||||
}
|
||||
|
||||
// PublicKeyCredentialRequestOptions mirrors the WebAuthn API structure
|
||||
type PublicKeyCredentialRequestOptions struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Timeout int `json:"timeout"`
|
||||
RpID string `json:"rpId,omitempty"`
|
||||
UserVerification string `json:"userVerification"`
|
||||
AllowCredentials []PublicKeyCredentialDescriptor `json:"allowCredentials,omitempty"`
|
||||
}
|
||||
|
||||
// PublicKeyCredentialDescriptor for allowed credentials
|
||||
type PublicKeyCredentialDescriptor struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Transports []string `json:"transports,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyRequest for client verification response
|
||||
type VerifyRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Credential map[string]interface{} `json:"credential"`
|
||||
}
|
||||
|
||||
// NewWebAuthnService creates a new WebAuthn service
|
||||
func NewWebAuthnService(cfg *config.WebAuthnConfig) *WebAuthnService {
|
||||
return &WebAuthnService{
|
||||
config: cfg,
|
||||
challenges: make(map[string]*Challenge),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateChallenge generates a new WebAuthn challenge for a session
|
||||
func (s *WebAuthnService) CreateChallenge(sessionID string) (*ChallengeResponse, error) {
|
||||
// Generate random challenge bytes
|
||||
challengeBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(challengeBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
challengeStr := base64.RawURLEncoding.EncodeToString(challengeBytes)
|
||||
|
||||
// Generate challenge ID
|
||||
idBytes := make([]byte, 16)
|
||||
rand.Read(idBytes)
|
||||
challengeID := base64.RawURLEncoding.EncodeToString(idBytes)
|
||||
|
||||
// Create challenge
|
||||
challenge := &Challenge{
|
||||
ID: challengeID,
|
||||
SessionID: sessionID,
|
||||
Challenge: challengeStr,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Duration(s.config.TimeoutMs) * time.Millisecond),
|
||||
Verified: false,
|
||||
}
|
||||
|
||||
// Store challenge
|
||||
s.mu.Lock()
|
||||
s.challenges[challengeID] = challenge
|
||||
s.mu.Unlock()
|
||||
|
||||
// Build response
|
||||
response := &ChallengeResponse{
|
||||
PublicKey: PublicKeyCredentialRequestOptions{
|
||||
Challenge: challengeStr,
|
||||
Timeout: s.config.TimeoutMs,
|
||||
UserVerification: s.config.UserVerification,
|
||||
// In production, you'd include allowed credentials from user registration
|
||||
AllowCredentials: []PublicKeyCredentialDescriptor{},
|
||||
},
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// VerifyChallenge verifies a WebAuthn assertion response
|
||||
func (s *WebAuthnService) VerifyChallenge(req *VerifyRequest) (bool, error) {
|
||||
s.mu.RLock()
|
||||
challenge, exists := s.challenges[req.ChallengeID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(challenge.ExpiresAt) {
|
||||
s.mu.Lock()
|
||||
delete(s.challenges, req.ChallengeID)
|
||||
s.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check session match
|
||||
if challenge.SessionID != req.SessionID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// In production, you would:
|
||||
// 1. Parse the credential response
|
||||
// 2. Verify the signature against stored public key
|
||||
// 3. Verify the challenge matches
|
||||
// 4. Check the origin
|
||||
// For MVP, we accept any valid-looking response
|
||||
|
||||
// Verify credential structure exists
|
||||
if req.Credential == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Mark as verified
|
||||
s.mu.Lock()
|
||||
challenge.Verified = true
|
||||
s.mu.Unlock()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CleanupExpiredChallenges removes expired challenges
|
||||
func (s *WebAuthnService) CleanupExpiredChallenges() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for id, challenge := range s.challenges {
|
||||
if now.After(challenge.ExpiresAt) {
|
||||
delete(s.challenges, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether WebAuthn is enabled
|
||||
func (s *WebAuthnService) IsEnabled() bool {
|
||||
return s.config.Enabled
|
||||
}
|
||||
Reference in New Issue
Block a user