Add configuration files, database migrations, and authentication implementation scaffolding

This commit is contained in:
Sebastian Unterschütz
2026-04-30 19:08:07 +02:00
commit 331d60581e
83 changed files with 222264 additions and 0 deletions

109
internal/auth/jwt.go Normal file
View 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
View 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
View 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
View 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
}

View 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);

View 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);

View 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
View 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
View 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,
}
}

View 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"`
}

View 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)

View 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
View 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()
}
}

View 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
}

View 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
}