Add configuration files, database migrations, and authentication implementation scaffolding
This commit is contained in:
109
internal/auth/jwt.go
Normal file
109
internal/auth/jwt.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Claims represents JWT payload
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
CommunityID string `json:"community_id"`
|
||||
Username string `json:"username"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
}
|
||||
|
||||
var jwtSecret = []byte("CHANGE-THIS-IN-PRODUCTION-USE-ENV-VAR") // TODO: Load from env
|
||||
|
||||
// GenerateJWT creates a signed JWT token
|
||||
func GenerateJWT(userID, communityID, username string, duration time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
CommunityID: communityID,
|
||||
Username: username,
|
||||
IssuedAt: now.Unix(),
|
||||
ExpiresAt: now.Add(duration).Unix(),
|
||||
}
|
||||
|
||||
// Create header
|
||||
header := map[string]string{
|
||||
"alg": "HS256",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
|
||||
// Base64URL encode
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||
|
||||
// Create signature
|
||||
message := headerB64 + "." + claimsB64
|
||||
signature := createSignature(message)
|
||||
|
||||
return message + "." + signature, nil
|
||||
}
|
||||
|
||||
// VerifyJWT validates and parses a JWT token
|
||||
func VerifyJWT(token string) (*Claims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
message := parts[0] + "." + parts[1]
|
||||
expectedSig := createSignature(message)
|
||||
if parts[2] != expectedSig {
|
||||
return nil, fmt.Errorf("invalid signature")
|
||||
}
|
||||
|
||||
// Decode claims
|
||||
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode claims: %w", err)
|
||||
}
|
||||
|
||||
var claims Claims
|
||||
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse claims: %w", err)
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().Unix() > claims.ExpiresAt {
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// createSignature generates HMAC-SHA256 signature
|
||||
func createSignature(message string) string {
|
||||
h := hmac.New(sha256.New, jwtSecret)
|
||||
h.Write([]byte(message))
|
||||
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// GenerateSessionToken creates a cryptographically secure session token
|
||||
func GenerateSessionToken() (string, error) {
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(token), nil
|
||||
}
|
||||
|
||||
// HashToken creates SHA256 hash for database storage
|
||||
func HashToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
100
internal/auth/password.go
Normal file
100
internal/auth/password.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
// Argon2 parameters (OWASP recommended)
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // 64 MB
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// HashPassword creates a secure hash using Argon2id
|
||||
func HashPassword(password string) (string, error) {
|
||||
// Generate random salt
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
|
||||
// Encode as: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks if password matches hash (constant-time comparison)
|
||||
func VerifyPassword(password, encodedHash string) (bool, error) {
|
||||
// Parse hash format
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, fmt.Errorf("invalid hash format")
|
||||
}
|
||||
|
||||
// Parse parameters
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Decode salt and hash
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Hash input password with same parameters
|
||||
hash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(expectedHash)))
|
||||
|
||||
// Constant-time comparison
|
||||
return subtle.ConstantTimeCompare(hash, expectedHash) == 1, nil
|
||||
}
|
||||
|
||||
// ValidatePasswordStrength checks password meets minimum requirements
|
||||
func ValidatePasswordStrength(password string) error {
|
||||
if len(password) < 12 {
|
||||
return fmt.Errorf("password must be at least 12 characters")
|
||||
}
|
||||
|
||||
hasUpper := false
|
||||
hasLower := false
|
||||
hasDigit := false
|
||||
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case 'A' <= char && char <= 'Z':
|
||||
hasUpper = true
|
||||
case 'a' <= char && char <= 'z':
|
||||
hasLower = true
|
||||
case '0' <= char && char <= '9':
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper || !hasLower || !hasDigit {
|
||||
return fmt.Errorf("password must contain uppercase, lowercase, and digits")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
72
internal/crypto/crypto.go
Normal file
72
internal/crypto/crypto.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Encrypt encrypts plaintext using AES-GCM with the provided key.
|
||||
// The key must be 32 bytes (AES-256).
|
||||
func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Seal appends the ciphertext to the nonce, so we return [nonce][ciphertext]
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ciphertext using AES-GCM with the provided key.
|
||||
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, actualCiphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
return gcm.Open(nil, nonce, actualCiphertext, nil)
|
||||
}
|
||||
|
||||
// GenerateBlindIndex creates a deterministic HMAC-SHA256 hash of a value.
|
||||
// This allows searching for encrypted data without knowing the plaintext.
|
||||
func GenerateBlindIndex(value string, salt []byte) string {
|
||||
h := hmac.New(sha256.New, salt)
|
||||
h.Write([]byte(value))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// GenerateKey generates a random 32-byte key.
|
||||
func GenerateKey() ([]byte, error) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
51
internal/db/db.go
Normal file
51
internal/db/db.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// Connect initializes a standard sql.DB connection with retries.
|
||||
func Connect(dsn string) (*sql.DB, error) {
|
||||
var db *sql.DB
|
||||
var err error
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
db, err = sql.Open("postgres", dsn)
|
||||
if err == nil {
|
||||
err = db.Ping()
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
}
|
||||
fmt.Printf("Failed to connect to database, retrying in 2s... (%d/10)\n", i+1)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to connect to database after retries: %w", err)
|
||||
}
|
||||
|
||||
// RunMigrations runs the SQL migrations from internal/db/migrations.
|
||||
func RunMigrations(dsn string) error {
|
||||
m, err := migrate.New(
|
||||
"file://internal/db/migrations",
|
||||
dsn,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create migrate instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
return fmt.Errorf("could not run up migrations: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Migrations completed successfully")
|
||||
return nil
|
||||
}
|
||||
25
internal/db/migrations/000001_init.up.sql
Normal file
25
internal/db/migrations/000001_init.up.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Migration: 000001_init.up.sql
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS encrypted_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
log_type TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
encrypted_payload BYTEA NOT NULL,
|
||||
blind_index_hash TEXT,
|
||||
server_id TEXT NOT NULL,
|
||||
session_id TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON encrypted_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_blind_hash ON encrypted_logs(blind_index_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS telemetry (
|
||||
timestamp TIMESTAMP WITH TIME ZONE PRIMARY KEY DEFAULT CURRENT_TIMESTAMP,
|
||||
community_id TEXT NOT NULL,
|
||||
server_fps DOUBLE PRECISION NOT NULL,
|
||||
player_count INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_community_id ON telemetry(community_id);
|
||||
77
internal/db/migrations/000002_webauthn.up.sql
Normal file
77
internal/db/migrations/000002_webauthn.up.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- Migration: 000002_webauthn.up.sql
|
||||
-- WebAuthn-based Authentication for Zero-Knowledge Admin Access
|
||||
|
||||
-- Communities table (represents each gaming community using the platform)
|
||||
CREATE TABLE IF NOT EXISTS communities (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
master_key_salt BYTEA NOT NULL, -- Used for key wrapping/unwrapping
|
||||
storage_node_id TEXT, -- Which storage node handles this community's data
|
||||
retention_days INTEGER DEFAULT 30 -- Auto-deletion policy (DSGVO)
|
||||
);
|
||||
|
||||
-- Admin users (co-owners of a community)
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_primary_owner BOOLEAN DEFAULT false,
|
||||
UNIQUE(community_id, username)
|
||||
);
|
||||
|
||||
-- WebAuthn credentials (hardware-bound authentication)
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id BYTEA PRIMARY KEY, -- Credential ID from WebAuthn
|
||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
public_key BYTEA NOT NULL,
|
||||
sign_count BIGINT NOT NULL DEFAULT 0,
|
||||
aaguid BYTEA, -- Authenticator AAGUID
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
device_name TEXT -- e.g., "YubiKey 5C", "Windows Hello"
|
||||
);
|
||||
|
||||
-- Wrapped master keys (encrypted with WebAuthn public key)
|
||||
CREATE TABLE IF NOT EXISTS wrapped_master_keys (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
|
||||
wrapped_key_data BYTEA NOT NULL, -- Master key encrypted for this admin
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(admin_user_id, community_id)
|
||||
);
|
||||
|
||||
-- Managed Trust Vault (for Discord Bot & external API integrations)
|
||||
CREATE TABLE IF NOT EXISTS managed_trust_vault (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
|
||||
service_name TEXT NOT NULL, -- e.g., "discord_bot", "external_api"
|
||||
encrypted_master_key BYTEA NOT NULL, -- Encrypted with provider's key
|
||||
granted_by UUID NOT NULL REFERENCES admin_users(id),
|
||||
expires_at TIMESTAMP WITH TIME ZONE, -- NULL = permanent, else temporary
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(community_id, service_name)
|
||||
);
|
||||
|
||||
-- Player roster for fast blind-index searching
|
||||
CREATE TABLE IF NOT EXISTS player_roster (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
|
||||
player_name_hash TEXT NOT NULL, -- HMAC hash for blind searching
|
||||
encrypted_player_data BYTEA NOT NULL, -- Contains name, Steam ID, etc.
|
||||
first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(community_id, player_name_hash)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_users_community ON admin_users(community_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_admin_user ON webauthn_credentials(admin_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wrapped_keys_admin ON wrapped_master_keys(admin_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_managed_trust_community ON managed_trust_vault(community_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_player_roster_community ON player_roster(community_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_player_roster_hash ON player_roster(player_name_hash);
|
||||
25
internal/db/migrations/000003_password_auth.up.sql
Normal file
25
internal/db/migrations/000003_password_auth.up.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Migration: 000003_password_auth.up.sql
|
||||
-- Add password authentication as optional fallback
|
||||
|
||||
-- Add password hash column to admin_users (nullable for Passkey-only accounts)
|
||||
ALTER TABLE admin_users ADD COLUMN password_hash TEXT;
|
||||
|
||||
-- Sessions table for JWT token management
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE, -- SHA256 hash of JWT
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
last_activity TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(admin_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
|
||||
-- Add auth_method to track how user logged in
|
||||
ALTER TABLE admin_users ADD COLUMN preferred_auth_method TEXT DEFAULT 'password';
|
||||
-- Options: 'password', 'passkey', 'both'
|
||||
19
internal/db/queries.sql
Normal file
19
internal/db/queries.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- name: CreateEncryptedLog :one
|
||||
INSERT INTO encrypted_logs (
|
||||
log_type, encrypted_payload, blind_index_hash, server_id, session_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
) RETURNING *;
|
||||
|
||||
-- name: CreateTelemetry :one
|
||||
INSERT INTO telemetry (
|
||||
community_id, server_fps, player_count
|
||||
) VALUES (
|
||||
$1, $2, $3
|
||||
) RETURNING *;
|
||||
|
||||
-- name: GetRecentLogs :many
|
||||
SELECT * FROM encrypted_logs
|
||||
WHERE server_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2;
|
||||
31
internal/db/sqlc/db.go
Normal file
31
internal/db/sqlc/db.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
29
internal/db/sqlc/models.go
Normal file
29
internal/db/sqlc/models.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type EncryptedLog struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
LogType string `json:"log_type"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
EncryptedPayload []byte `json:"encrypted_payload"`
|
||||
BlindIndexHash sql.NullString `json:"blind_index_hash"`
|
||||
ServerID string `json:"server_id"`
|
||||
SessionID sql.NullString `json:"session_id"`
|
||||
}
|
||||
|
||||
type Telemetry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
CommunityID string `json:"community_id"`
|
||||
ServerFps float64 `json:"server_fps"`
|
||||
PlayerCount int32 `json:"player_count"`
|
||||
}
|
||||
17
internal/db/sqlc/querier.go
Normal file
17
internal/db/sqlc/querier.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
CreateEncryptedLog(ctx context.Context, arg CreateEncryptedLogParams) (EncryptedLog, error)
|
||||
CreateTelemetry(ctx context.Context, arg CreateTelemetryParams) (Telemetry, error)
|
||||
GetRecentLogs(ctx context.Context, arg GetRecentLogsParams) ([]EncryptedLog, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
117
internal/db/sqlc/queries.sql.go
Normal file
117
internal/db/sqlc/queries.sql.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: queries.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createEncryptedLog = `-- name: CreateEncryptedLog :one
|
||||
INSERT INTO encrypted_logs (
|
||||
log_type, encrypted_payload, blind_index_hash, server_id, session_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
) RETURNING id, log_type, created_at, encrypted_payload, blind_index_hash, server_id, session_id
|
||||
`
|
||||
|
||||
type CreateEncryptedLogParams struct {
|
||||
LogType string `json:"log_type"`
|
||||
EncryptedPayload []byte `json:"encrypted_payload"`
|
||||
BlindIndexHash sql.NullString `json:"blind_index_hash"`
|
||||
ServerID string `json:"server_id"`
|
||||
SessionID sql.NullString `json:"session_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateEncryptedLog(ctx context.Context, arg CreateEncryptedLogParams) (EncryptedLog, error) {
|
||||
row := q.db.QueryRowContext(ctx, createEncryptedLog,
|
||||
arg.LogType,
|
||||
arg.EncryptedPayload,
|
||||
arg.BlindIndexHash,
|
||||
arg.ServerID,
|
||||
arg.SessionID,
|
||||
)
|
||||
var i EncryptedLog
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.LogType,
|
||||
&i.CreatedAt,
|
||||
&i.EncryptedPayload,
|
||||
&i.BlindIndexHash,
|
||||
&i.ServerID,
|
||||
&i.SessionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createTelemetry = `-- name: CreateTelemetry :one
|
||||
INSERT INTO telemetry (
|
||||
community_id, server_fps, player_count
|
||||
) VALUES (
|
||||
$1, $2, $3
|
||||
) RETURNING timestamp, community_id, server_fps, player_count
|
||||
`
|
||||
|
||||
type CreateTelemetryParams struct {
|
||||
CommunityID string `json:"community_id"`
|
||||
ServerFps float64 `json:"server_fps"`
|
||||
PlayerCount int32 `json:"player_count"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateTelemetry(ctx context.Context, arg CreateTelemetryParams) (Telemetry, error) {
|
||||
row := q.db.QueryRowContext(ctx, createTelemetry, arg.CommunityID, arg.ServerFps, arg.PlayerCount)
|
||||
var i Telemetry
|
||||
err := row.Scan(
|
||||
&i.Timestamp,
|
||||
&i.CommunityID,
|
||||
&i.ServerFps,
|
||||
&i.PlayerCount,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRecentLogs = `-- name: GetRecentLogs :many
|
||||
SELECT id, log_type, created_at, encrypted_payload, blind_index_hash, server_id, session_id FROM encrypted_logs
|
||||
WHERE server_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type GetRecentLogsParams struct {
|
||||
ServerID string `json:"server_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRecentLogs(ctx context.Context, arg GetRecentLogsParams) ([]EncryptedLog, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getRecentLogs, arg.ServerID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []EncryptedLog
|
||||
for rows.Next() {
|
||||
var i EncryptedLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.LogType,
|
||||
&i.CreatedAt,
|
||||
&i.EncryptedPayload,
|
||||
&i.BlindIndexHash,
|
||||
&i.ServerID,
|
||||
&i.SessionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
64
internal/nats/nats.go
Normal file
64
internal/nats/nats.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package nats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Conn *nats.Conn
|
||||
JS jetstream.JetStream
|
||||
}
|
||||
|
||||
// Connect initializes a NATS connection and JetStream context.
|
||||
func Connect(url string) (*Client, error) {
|
||||
nc, err := nats.Connect(url,
|
||||
nats.Name("SimpleArmaAdmin"),
|
||||
nats.MaxReconnects(-1), // Infinite retries
|
||||
nats.ReconnectWait(2*time.Second),
|
||||
nats.RetryOnFailedConnect(true), // Important for Docker startup
|
||||
nats.Timeout(5*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
|
||||
}
|
||||
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, fmt.Errorf("failed to create JetStream context: %w", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
Conn: nc,
|
||||
JS: js,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupStream ensures a JetStream stream exists for logs.
|
||||
func (c *Client) SetupStream(ctx context.Context, streamName string, subjects []string) error {
|
||||
_, err := c.JS.CreateOrUpdateStream(ctx, jetstream.StreamConfig{
|
||||
Name: streamName,
|
||||
Subjects: subjects,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: 30 * 24 * time.Hour, // 30 days retention as per blueprint
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishLog sends an encrypted log blob to a specific subject.
|
||||
func (c *Client) PublishLog(ctx context.Context, communityID string, logType string, data []byte) error {
|
||||
subject := fmt.Sprintf("logs.%s.%s", communityID, logType)
|
||||
_, err := c.JS.Publish(ctx, subject, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
if c.Conn != nil {
|
||||
c.Conn.Close()
|
||||
}
|
||||
}
|
||||
59
internal/parser/reforger.go
Normal file
59
internal/parser/reforger.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogEvent struct {
|
||||
Timestamp time.Time
|
||||
Type string // 'CHAT', 'JOIN', 'LEAVE', 'ADMIN', 'GENERIC'
|
||||
Content string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// Reforger-specific regex patterns
|
||||
var (
|
||||
// Example: 12:30:01.122 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: hi, leute...
|
||||
chatRegex = regexp.MustCompile(`^(\d{2}:\d{2}:\d{2}\.\d{3})\s+SCRIPT\s+:\s+\[.*?\]\[Chat\]\s+\[.*?\]\s+(.*?):\s+(.*)$`)
|
||||
// Example: 09:37:50.865 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta (92.209.175.19:6679) connected'
|
||||
joinRegex = regexp.MustCompile(`^(\d{2}:\d{2}:\d{2}\.\d{3})\s+DEFAULT\s+:\s+BattlEye Server:\s+'Player #\d+\s+(.*?)\s+\(.*?\) connected'$`)
|
||||
// Example: 09:38:53.842 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta disconnected'
|
||||
leaveRegex = regexp.MustCompile(`^(\d{2}:\d{2}:\d{2}\.\d{3})\s+DEFAULT\s+:\s+BattlEye Server:\s+'Player #\d+\s+(.*?) disconnected'$`)
|
||||
)
|
||||
|
||||
func ParseLine(line string) *LogEvent {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
event := &LogEvent{
|
||||
Raw: line,
|
||||
Type: "GENERIC",
|
||||
}
|
||||
|
||||
// Try Chat
|
||||
if matches := chatRegex.FindStringSubmatch(line); matches != nil {
|
||||
event.Type = "CHAT"
|
||||
event.Content = matches[2] + ": " + matches[3]
|
||||
return event
|
||||
}
|
||||
|
||||
// Try Join
|
||||
if matches := joinRegex.FindStringSubmatch(line); matches != nil {
|
||||
event.Type = "JOIN"
|
||||
event.Content = matches[2] + " connected to server"
|
||||
return event
|
||||
}
|
||||
|
||||
// Try Leave
|
||||
if matches := leaveRegex.FindStringSubmatch(line); matches != nil {
|
||||
event.Type = "LEAVE"
|
||||
event.Content = matches[2] + " left the server"
|
||||
return event
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
155
internal/webauthn/webauthn.go
Normal file
155
internal/webauthn/webauthn.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PublicKeyCredentialRequestOptions represents the WebAuthn authentication challenge
|
||||
type PublicKeyCredentialRequestOptions struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Timeout int `json:"timeout"`
|
||||
RPID string `json:"rpId"`
|
||||
AllowCredentials []string `json:"allowCredentials,omitempty"`
|
||||
UserVerification string `json:"userVerification"`
|
||||
}
|
||||
|
||||
// PublicKeyCredentialCreationOptions represents the WebAuthn registration challenge
|
||||
type PublicKeyCredentialCreationOptions struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RP RelyingParty `json:"rp"`
|
||||
User User `json:"user"`
|
||||
PubKeyCredParams []PubKeyCredParam `json:"pubKeyCredParams"`
|
||||
Timeout int `json:"timeout"`
|
||||
Attestation string `json:"attestation"`
|
||||
AuthenticatorSelection AuthenticatorSelection `json:"authenticatorSelection"`
|
||||
}
|
||||
|
||||
type RelyingParty struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
type PubKeyCredParam struct {
|
||||
Type string `json:"type"`
|
||||
Alg int `json:"alg"`
|
||||
}
|
||||
|
||||
type AuthenticatorSelection struct {
|
||||
AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"`
|
||||
RequireResidentKey bool `json:"requireResidentKey"`
|
||||
UserVerification string `json:"userVerification"`
|
||||
}
|
||||
|
||||
// GenerateChallenge creates a cryptographically secure random challenge
|
||||
func GenerateChallenge() (string, error) {
|
||||
challenge := make([]byte, 32)
|
||||
_, err := rand.Read(challenge)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(challenge), nil
|
||||
}
|
||||
|
||||
// CreateRegistrationOptions generates WebAuthn registration options
|
||||
func CreateRegistrationOptions(userID, username, displayName, rpName, rpID string) (*PublicKeyCredentialCreationOptions, error) {
|
||||
challenge, err := GenerateChallenge()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PublicKeyCredentialCreationOptions{
|
||||
Challenge: challenge,
|
||||
RP: RelyingParty{
|
||||
Name: rpName,
|
||||
ID: rpID,
|
||||
},
|
||||
User: User{
|
||||
ID: userID,
|
||||
Name: username,
|
||||
DisplayName: displayName,
|
||||
},
|
||||
PubKeyCredParams: []PubKeyCredParam{
|
||||
{Type: "public-key", Alg: -7}, // ES256
|
||||
{Type: "public-key", Alg: -257}, // RS256
|
||||
},
|
||||
Timeout: 60000,
|
||||
Attestation: "none",
|
||||
AuthenticatorSelection: AuthenticatorSelection{
|
||||
RequireResidentKey: false,
|
||||
UserVerification: "preferred",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateAuthenticationOptions generates WebAuthn authentication options
|
||||
func CreateAuthenticationOptions(rpID string, allowedCredentials []string) (*PublicKeyCredentialRequestOptions, error) {
|
||||
challenge, err := GenerateChallenge()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PublicKeyCredentialRequestOptions{
|
||||
Challenge: challenge,
|
||||
Timeout: 60000,
|
||||
RPID: rpID,
|
||||
AllowCredentials: allowedCredentials,
|
||||
UserVerification: "preferred",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyClientData validates the WebAuthn client data JSON
|
||||
func VerifyClientData(clientDataJSON []byte, expectedChallenge, expectedOrigin string) error {
|
||||
var clientData struct {
|
||||
Type string `json:"type"`
|
||||
Challenge string `json:"challenge"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(clientDataJSON, &clientData); err != nil {
|
||||
return fmt.Errorf("invalid client data JSON: %w", err)
|
||||
}
|
||||
|
||||
if clientData.Type != "webauthn.get" && clientData.Type != "webauthn.create" {
|
||||
return fmt.Errorf("invalid client data type: %s", clientData.Type)
|
||||
}
|
||||
|
||||
if clientData.Challenge != expectedChallenge {
|
||||
return fmt.Errorf("challenge mismatch")
|
||||
}
|
||||
|
||||
if clientData.Origin != expectedOrigin {
|
||||
return fmt.Errorf("origin mismatch: expected %s, got %s", expectedOrigin, clientData.Origin)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashClientData creates SHA-256 hash of client data (required for signature verification)
|
||||
func HashClientData(clientDataJSON []byte) []byte {
|
||||
hash := sha256.Sum256(clientDataJSON)
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
// WrapMasterKey encrypts the master key with the admin's public key
|
||||
// In a production system, this would use the WebAuthn credential's public key
|
||||
func WrapMasterKey(masterKey []byte, publicKey []byte) ([]byte, error) {
|
||||
// Simplified implementation - in production, use proper public key encryption
|
||||
// For now, we'll use a symmetric approach with HKDF
|
||||
return masterKey, nil // TODO: Implement proper key wrapping
|
||||
}
|
||||
|
||||
// UnwrapMasterKey decrypts the master key using the admin's credential
|
||||
func UnwrapMasterKey(wrappedKey []byte, privateKey []byte) ([]byte, error) {
|
||||
// Simplified implementation - in production, use proper public key decryption
|
||||
return wrappedKey, nil // TODO: Implement proper key unwrapping
|
||||
}
|
||||
Reference in New Issue
Block a user