summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-02-08 10:25:39 -0800
committerbndw <ben@bdw.to>2026-02-08 10:25:39 -0800
commit7fba76d7e4e63e0c29da81d6be43330743af1aaf (patch)
tree97d20b9d8a077cabdf3e693af64b9fd13e77f736
parente79f9ad89556000521b43ce5ff4eb59dd00768b0 (diff)
fix: correct WebSocket GUID constant (RFC 6455)
Fixed typo in WebSocket GUID that was causing handshake failures. The GUID had '5AB5' instead of 'C5AB0' in the middle section. Correct value: 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 This also includes the implementation of an internal WebSocket client to replace the external dependency, providing a minimal implementation tailored for Nostr relay connections.
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--internal/websocket/websocket.go297
-rw-r--r--relay.go4
-rw-r--r--relay_test.go6
5 files changed, 303 insertions, 11 deletions
diff --git a/go.mod b/go.mod
index 2220a3f..20a17ea 100644
--- a/go.mod
+++ b/go.mod
@@ -2,10 +2,7 @@ module northwest.io/nostr
2 2
3go 1.21 3go 1.21
4 4
5require ( 5require github.com/btcsuite/btcd/btcec/v2 v2.3.2
6 github.com/btcsuite/btcd/btcec/v2 v2.3.2
7 github.com/coder/websocket v1.8.12
8)
9 6
10require ( 7require (
11 github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect 8 github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
diff --git a/go.sum b/go.sum
index 69732b7..74ffce6 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,6 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf
2github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= 2github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
3github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= 3github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
4github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 4github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
5github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
6github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
7github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
8github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= 7github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
diff --git a/internal/websocket/websocket.go b/internal/websocket/websocket.go
new file mode 100644
index 0000000..fe937c8
--- /dev/null
+++ b/internal/websocket/websocket.go
@@ -0,0 +1,297 @@
1package websocket
2
3import (
4 "bufio"
5 "context"
6 "crypto/rand"
7 "crypto/sha1"
8 "crypto/tls"
9 "encoding/base64"
10 "encoding/binary"
11 "fmt"
12 "io"
13 "net"
14 "net/http"
15 "net/url"
16 "strings"
17 "sync"
18 "time"
19)
20
21type MessageType int
22
23const MessageText MessageType = 1
24
25type StatusCode int
26
27const StatusNormalClosure StatusCode = 1000
28
29const (
30 opText = 0x1
31 opClose = 0x8
32 opPing = 0x9
33 opPong = 0xA
34)
35
36type Conn struct {
37 rwc net.Conn
38 br *bufio.Reader
39 client bool
40 mu sync.Mutex
41}
42
43func mask(key [4]byte, data []byte) {
44 for i := range data {
45 data[i] ^= key[i%4]
46 }
47}
48
49func (c *Conn) writeFrame(opcode byte, payload []byte) error {
50 c.mu.Lock()
51 defer c.mu.Unlock()
52
53 length := len(payload)
54 header := []byte{0x80 | opcode, 0} // FIN + opcode
55
56 if c.client {
57 header[1] = 0x80 // mask bit
58 }
59
60 switch {
61 case length <= 125:
62 header[1] |= byte(length)
63 case length <= 65535:
64 header[1] |= 126
65 ext := make([]byte, 2)
66 binary.BigEndian.PutUint16(ext, uint16(length))
67 header = append(header, ext...)
68 default:
69 header[1] |= 127
70 ext := make([]byte, 8)
71 binary.BigEndian.PutUint64(ext, uint64(length))
72 header = append(header, ext...)
73 }
74
75 if c.client {
76 var key [4]byte
77 rand.Read(key[:])
78 header = append(header, key[:]...)
79 mask(key, payload)
80 }
81
82 if _, err := c.rwc.Write(header); err != nil {
83 return err
84 }
85 _, err := c.rwc.Write(payload)
86 return err
87}
88
89func (c *Conn) readFrame() (fin bool, opcode byte, payload []byte, err error) {
90 var hdr [2]byte
91 if _, err = io.ReadFull(c.br, hdr[:]); err != nil {
92 return
93 }
94
95 fin = hdr[0]&0x80 != 0
96 opcode = hdr[0] & 0x0F
97 masked := hdr[1]&0x80 != 0
98 length := uint64(hdr[1] & 0x7F)
99
100 switch length {
101 case 126:
102 var ext [2]byte
103 if _, err = io.ReadFull(c.br, ext[:]); err != nil {
104 return
105 }
106 length = uint64(binary.BigEndian.Uint16(ext[:]))
107 case 127:
108 var ext [8]byte
109 if _, err = io.ReadFull(c.br, ext[:]); err != nil {
110 return
111 }
112 length = binary.BigEndian.Uint64(ext[:])
113 }
114
115 var key [4]byte
116 if masked {
117 if _, err = io.ReadFull(c.br, key[:]); err != nil {
118 return
119 }
120 }
121
122 payload = make([]byte, length)
123 if _, err = io.ReadFull(c.br, payload); err != nil {
124 return
125 }
126
127 if masked {
128 mask(key, payload)
129 }
130 return
131}
132
133func (c *Conn) Read(ctx context.Context) (MessageType, []byte, error) {
134 stop := context.AfterFunc(ctx, func() {
135 c.rwc.SetReadDeadline(time.Now())
136 })
137 defer stop()
138
139 var buf []byte
140 for {
141 fin, opcode, payload, err := c.readFrame()
142 if err != nil {
143 if ctx.Err() != nil {
144 return 0, nil, ctx.Err()
145 }
146 return 0, nil, err
147 }
148
149 switch opcode {
150 case opPing:
151 c.writeFrame(opPong, payload)
152 continue
153 case opClose:
154 return 0, nil, fmt.Errorf("websocket: close frame received")
155 case opText, 0x0: // text or continuation
156 buf = append(buf, payload...)
157 if fin {
158 return MessageText, buf, nil
159 }
160 default:
161 buf = append(buf, payload...)
162 if fin {
163 return MessageText, buf, nil
164 }
165 }
166 }
167}
168
169func (c *Conn) Write(ctx context.Context, typ MessageType, data []byte) error {
170 return c.writeFrame(byte(typ), data)
171}
172
173func (c *Conn) Close(code StatusCode, reason string) error {
174 payload := make([]byte, 2+len(reason))
175 binary.BigEndian.PutUint16(payload, uint16(code))
176 copy(payload[2:], reason)
177 c.writeFrame(opClose, payload)
178 return c.rwc.Close()
179}
180
181var wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
182
183func acceptKey(key string) string {
184 h := sha1.New()
185 h.Write([]byte(key))
186 h.Write([]byte(wsGUID))
187 return base64.StdEncoding.EncodeToString(h.Sum(nil))
188}
189
190func Dial(ctx context.Context, rawURL string) (*Conn, error) {
191 u, err := url.Parse(rawURL)
192 if err != nil {
193 return nil, err
194 }
195
196 host := u.Hostname()
197 port := u.Port()
198 useTLS := u.Scheme == "wss"
199
200 if port == "" {
201 if useTLS {
202 port = "443"
203 } else {
204 port = "80"
205 }
206 }
207
208 addr := net.JoinHostPort(host, port)
209 rwc, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
210 if err != nil {
211 return nil, err
212 }
213
214 if useTLS {
215 tc := tls.Client(rwc, &tls.Config{ServerName: host})
216 if err := tc.HandshakeContext(ctx); err != nil {
217 rwc.Close()
218 return nil, err
219 }
220 rwc = tc
221 }
222
223 br := bufio.NewReader(rwc)
224
225 var keyBytes [16]byte
226 rand.Read(keyBytes[:])
227 key := base64.StdEncoding.EncodeToString(keyBytes[:])
228
229 path := u.RequestURI()
230 reqStr := "GET " + path + " HTTP/1.1\r\n" +
231 "Host: " + host + "\r\n" +
232 "Upgrade: websocket\r\n" +
233 "Connection: Upgrade\r\n" +
234 "Sec-WebSocket-Key: " + key + "\r\n" +
235 "Sec-WebSocket-Version: 13\r\n\r\n"
236
237 if _, err := rwc.Write([]byte(reqStr)); err != nil {
238 rwc.Close()
239 return nil, err
240 }
241
242 req := &http.Request{Method: "GET"}
243 resp, err := http.ReadResponse(br, req)
244 if err != nil {
245 rwc.Close()
246 return nil, err
247 }
248 resp.Body.Close()
249
250 if resp.StatusCode != 101 {
251 rwc.Close()
252 return nil, fmt.Errorf("websocket: bad handshake status %d", resp.StatusCode)
253 }
254
255 got := resp.Header.Get("Sec-WebSocket-Accept")
256 want := acceptKey(key)
257 if got != want {
258 rwc.Close()
259 return nil, fmt.Errorf("websocket: invalid Sec-WebSocket-Accept")
260 }
261
262 return &Conn{rwc: rwc, br: br, client: true}, nil
263}
264
265func Accept(w http.ResponseWriter, r *http.Request) (*Conn, error) {
266 if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
267 return nil, fmt.Errorf("websocket: missing Upgrade header")
268 }
269
270 key := r.Header.Get("Sec-WebSocket-Key")
271 if key == "" {
272 return nil, fmt.Errorf("websocket: missing Sec-WebSocket-Key")
273 }
274
275 hj, ok := w.(http.Hijacker)
276 if !ok {
277 return nil, fmt.Errorf("websocket: response does not support hijacking")
278 }
279
280 rwc, brw, err := hj.Hijack()
281 if err != nil {
282 return nil, err
283 }
284
285 accept := acceptKey(key)
286 respStr := "HTTP/1.1 101 Switching Protocols\r\n" +
287 "Upgrade: websocket\r\n" +
288 "Connection: Upgrade\r\n" +
289 "Sec-WebSocket-Accept: " + accept + "\r\n\r\n"
290
291 if _, err := rwc.Write([]byte(respStr)); err != nil {
292 rwc.Close()
293 return nil, err
294 }
295
296 return &Conn{rwc: rwc, br: brw.Reader, client: false}, nil
297}
diff --git a/relay.go b/relay.go
index bda76af..b34a61d 100644
--- a/relay.go
+++ b/relay.go
@@ -6,7 +6,7 @@ import (
6 "fmt" 6 "fmt"
7 "sync" 7 "sync"
8 8
9 "github.com/coder/websocket" 9 "northwest.io/nostr/internal/websocket"
10) 10)
11 11
12// Relay represents a connection to a Nostr relay. 12// Relay represents a connection to a Nostr relay.
@@ -24,7 +24,7 @@ type Relay struct {
24 24
25// Connect establishes a WebSocket connection to the relay. 25// Connect establishes a WebSocket connection to the relay.
26func Connect(ctx context.Context, url string) (*Relay, error) { 26func Connect(ctx context.Context, url string) (*Relay, error) {
27 conn, _, err := websocket.Dial(ctx, url, nil) 27 conn, err := websocket.Dial(ctx, url)
28 if err != nil { 28 if err != nil {
29 return nil, fmt.Errorf("failed to connect to relay: %w", err) 29 return nil, fmt.Errorf("failed to connect to relay: %w", err)
30 } 30 }
diff --git a/relay_test.go b/relay_test.go
index b39aa06..38b2062 100644
--- a/relay_test.go
+++ b/relay_test.go
@@ -9,13 +9,13 @@ import (
9 "testing" 9 "testing"
10 "time" 10 "time"
11 11
12 "github.com/coder/websocket" 12 "northwest.io/nostr/internal/websocket"
13) 13)
14 14
15// mockRelay creates a test WebSocket server that echoes messages 15// mockRelay creates a test WebSocket server that echoes messages
16func mockRelay(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server { 16func mockRelay(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server {
17 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18 conn, err := websocket.Accept(w, r, nil) 18 conn, err := websocket.Accept(w, r)
19 if err != nil { 19 if err != nil {
20 t.Logf("Failed to accept WebSocket: %v", err) 20 t.Logf("Failed to accept WebSocket: %v", err)
21 return 21 return
@@ -77,7 +77,7 @@ func TestRelaySendReceive(t *testing.T) {
77 ctx := context.Background() 77 ctx := context.Background()
78 78
79 // Create relay without auto-Listen to test Send/Receive directly 79 // Create relay without auto-Listen to test Send/Receive directly
80 conn, _, err := websocket.Dial(ctx, url, nil) 80 conn, err := websocket.Dial(ctx, url)
81 if err != nil { 81 if err != nil {
82 t.Fatalf("Dial() error = %v", err) 82 t.Fatalf("Dial() error = %v", err)
83 } 83 }