From 212154fc29e3631d13cf7af9a0a3046c9683173b Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 14 Feb 2026 14:33:01 -0800 Subject: feat: implement NIP-42 WebSocket authentication Add support for authenticating WebSocket clients using NIP-42 protocol, enabling auth restrictions for normal Nostr clients. Storage layer (internal/storage/auth.go): - CreateAuthChallenge() - Generate random 32-byte challenge with 10min TTL - ValidateAndConsumeChallenge() - Verify challenge validity and mark as used - CleanupExpiredChallenges() - Remove old challenges from database - Uses existing auth_challenges table WebSocket handler (internal/handler/websocket/handler.go): - Track authenticatedPubkey per connection - Track authChallenge per connection - requireAuth() - Check if operation requires authentication - handleAuth() - Process AUTH responses (kind 22242 events) - sendAuthChallenge() - Send AUTH challenge to client - Enforce auth on EVENT (writes) and REQ (reads) messages - Support separate read/write allowlists Main (cmd/relay/main.go): - Wire auth config from YAML to WebSocket handler - Pass read/write enabled flags and allowed npub lists NIP-42 Flow: 1. Client sends EVENT/REQ without auth 2. If auth required, relay sends: ["AUTH", ""] 3. Client signs kind 22242 event with challenge tag 4. Client sends: ["AUTH", ] 5. Relay validates signature, challenge, and allowlist 6. Connection marked as authenticated 7. Client can now EVENT/REQ Example config to restrict writes to your npub: ```yaml auth: write: enabled: true allowed_npubs: - npub1your-npub-here... ``` WebSocket clients (Damus, Amethyst, etc.) can now authenticate! --- internal/storage/auth.go | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 internal/storage/auth.go (limited to 'internal/storage/auth.go') diff --git a/internal/storage/auth.go b/internal/storage/auth.go new file mode 100644 index 0000000..6eefa41 --- /dev/null +++ b/internal/storage/auth.go @@ -0,0 +1,88 @@ +package storage + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +const ( + ChallengeLength = 32 // bytes + ChallengeTTL = 10 * time.Minute +) + +func generateChallenge() (string, error) { + bytes := make([]byte, ChallengeLength) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate challenge: %w", err) + } + return hex.EncodeToString(bytes), nil +} + +func (s *Storage) CreateAuthChallenge(ctx context.Context) (string, error) { + challenge, err := generateChallenge() + if err != nil { + return "", err + } + + now := time.Now().Unix() + expiresAt := time.Now().Add(ChallengeTTL).Unix() + + _, err = s.db.ExecContext(ctx, + "INSERT INTO auth_challenges (challenge, created_at, expires_at, used) VALUES (?, ?, ?, 0)", + challenge, now, expiresAt, + ) + if err != nil { + return "", fmt.Errorf("failed to store challenge: %w", err) + } + + return challenge, nil +} + +func (s *Storage) ValidateAndConsumeChallenge(ctx context.Context, challenge string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + var expiresAt int64 + var used int + err = tx.QueryRowContext(ctx, + "SELECT expires_at, used FROM auth_challenges WHERE challenge = ?", + challenge, + ).Scan(&expiresAt, &used) + + if err != nil { + return fmt.Errorf("challenge not found or invalid") + } + + if used != 0 { + return fmt.Errorf("challenge already used") + } + + if time.Now().Unix() > expiresAt { + return fmt.Errorf("challenge expired") + } + + _, err = tx.ExecContext(ctx, + "UPDATE auth_challenges SET used = 1 WHERE challenge = ?", + challenge, + ) + if err != nil { + return fmt.Errorf("failed to mark challenge as used: %w", err) + } + + return tx.Commit() +} + +func (s *Storage) CleanupExpiredChallenges(ctx context.Context) error { + now := time.Now().Unix() + _, err := s.db.ExecContext(ctx, + "DELETE FROM auth_challenges WHERE expires_at < ?", + now, + ) + return err +} -- cgit v1.2.3