summaryrefslogtreecommitdiffstats
path: root/internal/storage/auth.go
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-14 14:33:01 -0800
committerbndw <ben@bdw.to>2026-02-14 14:33:01 -0800
commit212154fc29e3631d13cf7af9a0a3046c9683173b (patch)
tree8eddb5e01d25dfadcd078bd10274d13f2d936d06 /internal/storage/auth.go
parentdbfc55ed1aec5faefacfcfbd51c4de06b316fa90 (diff)
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", "<challenge>"] 3. Client signs kind 22242 event with challenge tag 4. Client sends: ["AUTH", <signed-event>] 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!
Diffstat (limited to 'internal/storage/auth.go')
-rw-r--r--internal/storage/auth.go88
1 files changed, 88 insertions, 0 deletions
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 @@
1package storage
2
3import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "fmt"
8 "time"
9)
10
11const (
12 ChallengeLength = 32 // bytes
13 ChallengeTTL = 10 * time.Minute
14)
15
16func generateChallenge() (string, error) {
17 bytes := make([]byte, ChallengeLength)
18 if _, err := rand.Read(bytes); err != nil {
19 return "", fmt.Errorf("failed to generate challenge: %w", err)
20 }
21 return hex.EncodeToString(bytes), nil
22}
23
24func (s *Storage) CreateAuthChallenge(ctx context.Context) (string, error) {
25 challenge, err := generateChallenge()
26 if err != nil {
27 return "", err
28 }
29
30 now := time.Now().Unix()
31 expiresAt := time.Now().Add(ChallengeTTL).Unix()
32
33 _, err = s.db.ExecContext(ctx,
34 "INSERT INTO auth_challenges (challenge, created_at, expires_at, used) VALUES (?, ?, ?, 0)",
35 challenge, now, expiresAt,
36 )
37 if err != nil {
38 return "", fmt.Errorf("failed to store challenge: %w", err)
39 }
40
41 return challenge, nil
42}
43
44func (s *Storage) ValidateAndConsumeChallenge(ctx context.Context, challenge string) error {
45 tx, err := s.db.BeginTx(ctx, nil)
46 if err != nil {
47 return fmt.Errorf("failed to begin transaction: %w", err)
48 }
49 defer tx.Rollback()
50
51 var expiresAt int64
52 var used int
53 err = tx.QueryRowContext(ctx,
54 "SELECT expires_at, used FROM auth_challenges WHERE challenge = ?",
55 challenge,
56 ).Scan(&expiresAt, &used)
57
58 if err != nil {
59 return fmt.Errorf("challenge not found or invalid")
60 }
61
62 if used != 0 {
63 return fmt.Errorf("challenge already used")
64 }
65
66 if time.Now().Unix() > expiresAt {
67 return fmt.Errorf("challenge expired")
68 }
69
70 _, err = tx.ExecContext(ctx,
71 "UPDATE auth_challenges SET used = 1 WHERE challenge = ?",
72 challenge,
73 )
74 if err != nil {
75 return fmt.Errorf("failed to mark challenge as used: %w", err)
76 }
77
78 return tx.Commit()
79}
80
81func (s *Storage) CleanupExpiredChallenges(ctx context.Context) error {
82 now := time.Now().Unix()
83 _, err := s.db.ExecContext(ctx,
84 "DELETE FROM auth_challenges WHERE expires_at < ?",
85 now,
86 )
87 return err
88}