From 7fba76d7e4e63e0c29da81d6be43330743af1aaf Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 8 Feb 2026 10:25:39 -0800 Subject: 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. --- go.mod | 5 +- go.sum | 2 - internal/websocket/websocket.go | 297 ++++++++++++++++++++++++++++++++++++++++ relay.go | 4 +- relay_test.go | 6 +- 5 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 internal/websocket/websocket.go 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 go 1.21 -require ( - github.com/btcsuite/btcd/btcec/v2 v2.3.2 - github.com/coder/websocket v1.8.12 -) +require github.com/btcsuite/btcd/btcec/v2 v2.3.2 require ( 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 github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.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 @@ +package websocket + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/sha1" + "crypto/tls" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type MessageType int + +const MessageText MessageType = 1 + +type StatusCode int + +const StatusNormalClosure StatusCode = 1000 + +const ( + opText = 0x1 + opClose = 0x8 + opPing = 0x9 + opPong = 0xA +) + +type Conn struct { + rwc net.Conn + br *bufio.Reader + client bool + mu sync.Mutex +} + +func mask(key [4]byte, data []byte) { + for i := range data { + data[i] ^= key[i%4] + } +} + +func (c *Conn) writeFrame(opcode byte, payload []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + + length := len(payload) + header := []byte{0x80 | opcode, 0} // FIN + opcode + + if c.client { + header[1] = 0x80 // mask bit + } + + switch { + case length <= 125: + header[1] |= byte(length) + case length <= 65535: + header[1] |= 126 + ext := make([]byte, 2) + binary.BigEndian.PutUint16(ext, uint16(length)) + header = append(header, ext...) + default: + header[1] |= 127 + ext := make([]byte, 8) + binary.BigEndian.PutUint64(ext, uint64(length)) + header = append(header, ext...) + } + + if c.client { + var key [4]byte + rand.Read(key[:]) + header = append(header, key[:]...) + mask(key, payload) + } + + if _, err := c.rwc.Write(header); err != nil { + return err + } + _, err := c.rwc.Write(payload) + return err +} + +func (c *Conn) readFrame() (fin bool, opcode byte, payload []byte, err error) { + var hdr [2]byte + if _, err = io.ReadFull(c.br, hdr[:]); err != nil { + return + } + + fin = hdr[0]&0x80 != 0 + opcode = hdr[0] & 0x0F + masked := hdr[1]&0x80 != 0 + length := uint64(hdr[1] & 0x7F) + + switch length { + case 126: + var ext [2]byte + if _, err = io.ReadFull(c.br, ext[:]); err != nil { + return + } + length = uint64(binary.BigEndian.Uint16(ext[:])) + case 127: + var ext [8]byte + if _, err = io.ReadFull(c.br, ext[:]); err != nil { + return + } + length = binary.BigEndian.Uint64(ext[:]) + } + + var key [4]byte + if masked { + if _, err = io.ReadFull(c.br, key[:]); err != nil { + return + } + } + + payload = make([]byte, length) + if _, err = io.ReadFull(c.br, payload); err != nil { + return + } + + if masked { + mask(key, payload) + } + return +} + +func (c *Conn) Read(ctx context.Context) (MessageType, []byte, error) { + stop := context.AfterFunc(ctx, func() { + c.rwc.SetReadDeadline(time.Now()) + }) + defer stop() + + var buf []byte + for { + fin, opcode, payload, err := c.readFrame() + if err != nil { + if ctx.Err() != nil { + return 0, nil, ctx.Err() + } + return 0, nil, err + } + + switch opcode { + case opPing: + c.writeFrame(opPong, payload) + continue + case opClose: + return 0, nil, fmt.Errorf("websocket: close frame received") + case opText, 0x0: // text or continuation + buf = append(buf, payload...) + if fin { + return MessageText, buf, nil + } + default: + buf = append(buf, payload...) + if fin { + return MessageText, buf, nil + } + } + } +} + +func (c *Conn) Write(ctx context.Context, typ MessageType, data []byte) error { + return c.writeFrame(byte(typ), data) +} + +func (c *Conn) Close(code StatusCode, reason string) error { + payload := make([]byte, 2+len(reason)) + binary.BigEndian.PutUint16(payload, uint16(code)) + copy(payload[2:], reason) + c.writeFrame(opClose, payload) + return c.rwc.Close() +} + +var wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +func acceptKey(key string) string { + h := sha1.New() + h.Write([]byte(key)) + h.Write([]byte(wsGUID)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +func Dial(ctx context.Context, rawURL string) (*Conn, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + host := u.Hostname() + port := u.Port() + useTLS := u.Scheme == "wss" + + if port == "" { + if useTLS { + port = "443" + } else { + port = "80" + } + } + + addr := net.JoinHostPort(host, port) + rwc, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + + if useTLS { + tc := tls.Client(rwc, &tls.Config{ServerName: host}) + if err := tc.HandshakeContext(ctx); err != nil { + rwc.Close() + return nil, err + } + rwc = tc + } + + br := bufio.NewReader(rwc) + + var keyBytes [16]byte + rand.Read(keyBytes[:]) + key := base64.StdEncoding.EncodeToString(keyBytes[:]) + + path := u.RequestURI() + reqStr := "GET " + path + " HTTP/1.1\r\n" + + "Host: " + host + "\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: " + key + "\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n" + + if _, err := rwc.Write([]byte(reqStr)); err != nil { + rwc.Close() + return nil, err + } + + req := &http.Request{Method: "GET"} + resp, err := http.ReadResponse(br, req) + if err != nil { + rwc.Close() + return nil, err + } + resp.Body.Close() + + if resp.StatusCode != 101 { + rwc.Close() + return nil, fmt.Errorf("websocket: bad handshake status %d", resp.StatusCode) + } + + got := resp.Header.Get("Sec-WebSocket-Accept") + want := acceptKey(key) + if got != want { + rwc.Close() + return nil, fmt.Errorf("websocket: invalid Sec-WebSocket-Accept") + } + + return &Conn{rwc: rwc, br: br, client: true}, nil +} + +func Accept(w http.ResponseWriter, r *http.Request) (*Conn, error) { + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + return nil, fmt.Errorf("websocket: missing Upgrade header") + } + + key := r.Header.Get("Sec-WebSocket-Key") + if key == "" { + return nil, fmt.Errorf("websocket: missing Sec-WebSocket-Key") + } + + hj, ok := w.(http.Hijacker) + if !ok { + return nil, fmt.Errorf("websocket: response does not support hijacking") + } + + rwc, brw, err := hj.Hijack() + if err != nil { + return nil, err + } + + accept := acceptKey(key) + respStr := "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n" + + if _, err := rwc.Write([]byte(respStr)); err != nil { + rwc.Close() + return nil, err + } + + return &Conn{rwc: rwc, br: brw.Reader, client: false}, nil +} diff --git a/relay.go b/relay.go index bda76af..b34a61d 100644 --- a/relay.go +++ b/relay.go @@ -6,7 +6,7 @@ import ( "fmt" "sync" - "github.com/coder/websocket" + "northwest.io/nostr/internal/websocket" ) // Relay represents a connection to a Nostr relay. @@ -24,7 +24,7 @@ type Relay struct { // Connect establishes a WebSocket connection to the relay. func Connect(ctx context.Context, url string) (*Relay, error) { - conn, _, err := websocket.Dial(ctx, url, nil) + conn, err := websocket.Dial(ctx, url) if err != nil { return nil, fmt.Errorf("failed to connect to relay: %w", err) } 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 ( "testing" "time" - "github.com/coder/websocket" + "northwest.io/nostr/internal/websocket" ) // mockRelay creates a test WebSocket server that echoes messages func mockRelay(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(w, r, nil) + conn, err := websocket.Accept(w, r) if err != nil { t.Logf("Failed to accept WebSocket: %v", err) return @@ -77,7 +77,7 @@ func TestRelaySendReceive(t *testing.T) { ctx := context.Background() // Create relay without auto-Listen to test Send/Receive directly - conn, _, err := websocket.Dial(ctx, url, nil) + conn, err := websocket.Dial(ctx, url) if err != nil { t.Fatalf("Dial() error = %v", err) } -- cgit v1.2.3