From acee5e3a90377c81320fa09ff3d00635bd1bac80 Mon Sep 17 00:00:00 2001 From: mleku Date: Sat, 30 Aug 2025 13:21:06 +0100 Subject: [PATCH] implement auth, closed and close envelopes --- app/handle-message.go | 6 +- app/handle-websocket.go | 10 +- app/main.go | 8 +- app/server.go | 6 +- go.mod | 2 - .../envelopes/authenvelope/authenvelope.go | 227 ++++++++++++++++++ .../authenvelope/authenvelope_test.go | 86 +++++++ .../closedenvelope/closedenvelope.go | 100 ++++++++ .../closedenvelope/closedenvelope_test.go | 85 +++++++ .../envelopes/closeenvelope/closeenvelope.go | 78 ++++++ .../closeenvelope/closeenvelope_test.go | 68 ++++++ pkg/encoders/envelopes/doc.go | 3 + pkg/encoders/envelopes/identify.go | 38 +++ pkg/encoders/envelopes/process.go | 41 ++++ pkg/encoders/event/canonical.go | 36 +++ pkg/encoders/event/event.go | 96 ++++++-- pkg/encoders/event/signatures.go | 48 ++++ pkg/encoders/kind/kind.go | 15 +- pkg/encoders/tag/tag.go | 49 +++- pkg/encoders/tag/tags.go | 21 ++ pkg/interfaces/codec/codec.go | 47 ++++ pkg/protocol/auth/nip42.go | 126 ++++++++++ pkg/protocol/auth/nip42_test.go | 34 +++ 23 files changed, 1186 insertions(+), 44 deletions(-) create mode 100644 pkg/encoders/envelopes/authenvelope/authenvelope.go create mode 100644 pkg/encoders/envelopes/authenvelope/authenvelope_test.go create mode 100644 pkg/encoders/envelopes/closedenvelope/closedenvelope.go create mode 100644 pkg/encoders/envelopes/closedenvelope/closedenvelope_test.go create mode 100644 pkg/encoders/envelopes/closeenvelope/closeenvelope.go create mode 100644 pkg/encoders/envelopes/closeenvelope/closeenvelope_test.go create mode 100644 pkg/encoders/envelopes/doc.go create mode 100644 pkg/encoders/envelopes/identify.go create mode 100644 pkg/encoders/envelopes/process.go create mode 100644 pkg/encoders/event/canonical.go create mode 100644 pkg/encoders/event/signatures.go create mode 100644 pkg/interfaces/codec/codec.go create mode 100644 pkg/protocol/auth/nip42.go create mode 100644 pkg/protocol/auth/nip42_test.go diff --git a/app/handle-message.go b/app/handle-message.go index 7456322..325b323 100644 --- a/app/handle-message.go +++ b/app/handle-message.go @@ -1,5 +1,9 @@ package app -func (s *Server) HandleMessage() { +import ( + "lol.mleku.dev/log" +) +func (s *Server) HandleMessage(msg []byte) { + log.I.F("received message:\n%s\n", msg) } diff --git a/app/handle-websocket.go b/app/handle-websocket.go index 009ce6c..84093f9 100644 --- a/app/handle-websocket.go +++ b/app/handle-websocket.go @@ -34,7 +34,7 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) { var err error var conn *websocket.Conn if conn, err = websocket.Accept( - w, r, &websocket.AcceptOptions{}, + w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}, ); chk.E(err) { return } @@ -48,8 +48,8 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) { default: } var typ websocket.MessageType - var message []byte - if typ, message, err = conn.Read(s.Ctx); err != nil { + var msg []byte + if typ, msg, err = conn.Read(s.Ctx); err != nil { if strings.Contains( err.Error(), "use of closed network connection", ) { @@ -68,12 +68,12 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) { return } if typ == PingMessage { - if err = conn.Write(s.Ctx, PongMessage, message); chk.E(err) { + if err = conn.Write(s.Ctx, PongMessage, msg); chk.E(err) { return } continue } - go s.HandleMessage() + go s.HandleMessage(msg) } } diff --git a/app/main.go b/app/main.go index d4de2d6..6335313 100644 --- a/app/main.go +++ b/app/main.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "lol.mleku.dev/chk" "lol.mleku.dev/log" "next.orly.dev/app/config" ) @@ -20,11 +21,14 @@ func Run(ctx context.Context, cfg *config.C) (quit chan struct{}) { }() // start listener l := &Server{ + Ctx: ctx, Config: cfg, } addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port) - log.I.F("starting listener on %s", addr) - go http.ListenAndServe(addr, l) + log.I.F("starting listener on http://%s", addr) + go func() { + chk.E(http.ListenAndServe(addr, l)) + }() quit = make(chan struct{}) return } diff --git a/app/server.go b/app/server.go index f0a892a..029ec99 100644 --- a/app/server.go +++ b/app/server.go @@ -21,6 +21,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else if r.Header.Get("Accept") == "application/nostr+json" { s.HandleRelayInfo(w, r) } else { - http.Error(w, "Upgrade required", http.StatusUpgradeRequired) + if s.mux == nil { + http.Error(w, "Upgrade required", http.StatusUpgradeRequired) + } else { + s.mux.ServeHTTP(w, r) + } } } diff --git a/go.mod b/go.mod index 37c14dd..ae964ff 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,6 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/felixge/fgprof v0.9.3 // indirect github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect - github.com/karrick/bufpool v1.2.0 // indirect - github.com/karrick/gopool v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/pkg/encoders/envelopes/authenvelope/authenvelope.go b/pkg/encoders/envelopes/authenvelope/authenvelope.go new file mode 100644 index 0000000..3acb549 --- /dev/null +++ b/pkg/encoders/envelopes/authenvelope/authenvelope.go @@ -0,0 +1,227 @@ +// Package authenvelope defines the auth challenge (relay message) and response +// (client message) of the NIP-42 authentication protocol. +package authenvelope + +import ( + "io" + + "lol.mleku.dev/chk" + "lol.mleku.dev/errorf" + "lol.mleku.dev/log" + envs "next.orly.dev/pkg/encoders/envelopes" + "next.orly.dev/pkg/encoders/event" + text2 "next.orly.dev/pkg/encoders/text" + "next.orly.dev/pkg/interfaces/codec" +) + +// L is the label associated with this type of codec.Envelope. +const L = "AUTH" + +// Challenge is the relay-sent message containing a relay-chosen random string +// to prevent replay attacks on NIP-42 authentication. +type Challenge struct { + Challenge []byte +} + +var _ codec.Envelope = (*Challenge)(nil) + +// NewChallenge creates a new empty authenvelope.Challenge. +func NewChallenge() *Challenge { return &Challenge{} } + +// NewChallengeWith creates a new authenvelope.Challenge with provided bytes. +func NewChallengeWith[V string | []byte](challenge V) *Challenge { + return &Challenge{[]byte(challenge)} +} + +// Label returns the label of a authenvelope.Challenge. +func (en *Challenge) Label() string { return L } + +// Write encodes and writes the Challenge instance to the provided writer. +// +// # Parameters +// +// - w (io.Writer): The destination where the encoded data will be written. +// +// # Return Values +// +// - err (error): An error if writing to the writer fails. +// +// # Expected behaviour +// +// Encodes the Challenge instance into a byte slice using Marshal, logs the +// encoded challenge, and writes it to the provided io.Writer. +func (en *Challenge) Write(w io.Writer) (err error) { + var b []byte + b = en.Marshal(b) + log.T.F("writing out challenge envelope: '%s'", b) + _, err = w.Write(b) + return +} + +// Marshal encodes the Challenge instance into a byte slice, formatting it as +// a JSON-like structure with a specific label and escaping rules applied to +// its content. +// +// # Parameters +// +// - dst ([]byte): The destination buffer where the encoded data will be written. +// +// # Return Values +// +// - b ([]byte): The byte slice containing the encoded Challenge data. +// +// # Expected behaviour +// +// - Prepares the destination buffer and applies a label to it. +// +// - Escapes the challenge content according to Nostr-specific rules before +// appending it to the output. +// +// - Returns the resulting byte slice with the complete encoded structure. +func (en *Challenge) Marshal(dst []byte) (b []byte) { + b = dst + var err error + b = envs.Marshal( + b, L, + func(bst []byte) (o []byte) { + o = bst + o = append(o, '"') + o = text2.NostrEscape(o, en.Challenge) + o = append(o, '"') + return + }, + ) + _ = err + return +} + +// Unmarshal parses the provided byte slice and extracts the challenge value, +// leaving any remaining bytes after parsing. +// +// # Parameters +// +// - b ([]byte): The byte slice containing the encoded challenge data. +// +// # Return Values +// +// - r ([]byte): Any remaining bytes after parsing the challenge. +// +// - err (error): An error if parsing fails. +// +// # Expected behaviour +// +// - Extracts the quoted challenge string from the input byte slice. +// +// - Trims any trailing characters following the closing quote. +func (en *Challenge) Unmarshal(b []byte) (r []byte, err error) { + r = b + if en.Challenge, r, err = text2.UnmarshalQuoted(r); chk.E(err) { + return + } + for ; len(r) >= 0; r = r[1:] { + if r[0] == ']' { + r = r[:0] + return + } + } + return +} + +// ParseChallenge parses the provided byte slice into a new Challenge instance, +// extracting the challenge value and returning any remaining bytes after parsing. +// +// # Parameters +// +// - b ([]byte): The byte slice containing the encoded challenge data. +// +// # Return Values +// +// - t (*Challenge): A pointer to the newly created and populated Challenge +// instance. +// +// - rem ([]byte): Any remaining bytes in the input slice after parsing. +// +// - err (error): An error if parsing fails. +// +// # Expected behaviour +// +// Parses the byte slice into a new Challenge instance using Unmarshal, +// returning any remaining bytes and an error if parsing fails. +func ParseChallenge(b []byte) (t *Challenge, rem []byte, err error) { + t = NewChallenge() + if rem, err = t.Unmarshal(b); chk.E(err) { + return + } + return +} + +// Response is a client-side envelope containing the signed event bearing the +// relay's URL and Challenge string. +type Response struct { + Event *event.E +} + +var _ codec.Envelope = (*Response)(nil) + +// NewResponse creates a new empty Response. +func NewResponse() *Response { return &Response{} } + +// NewResponseWith creates a new Response with a provided event.E. +func NewResponseWith(event *event.E) *Response { return &Response{Event: event} } + +// Label returns the label of a auth Response envelope. +func (en *Response) Label() string { return L } + +func (en *Response) Id() []byte { return en.Event.ID } + +// Write the Response to a provided io.Writer. +func (en *Response) Write(w io.Writer) (err error) { + var b []byte + b = en.Marshal(b) + _, err = w.Write(b) + return +} + +// Marshal a Response to minified JSON, appending to a provided destination +// slice. Note that this ensures correct string escaping on the challenge field. +func (en *Response) Marshal(dst []byte) (b []byte) { + var err error + if en == nil { + err = errorf.E("nil response") + return + } + if en.Event == nil { + err = errorf.E("nil event in response") + return + } + b = dst + b = envs.Marshal(b, L, en.Event.Marshal) + _ = err + return +} + +// Unmarshal a Response from minified JSON, returning the remainder after the en +// of the envelope. Note that this ensures the challenge string was correctly +// escaped by NIP-01 escaping rules. +func (en *Response) Unmarshal(b []byte) (r []byte, err error) { + r = b + // literally just unmarshal the event + en.Event = event.New() + if r, err = en.Event.Unmarshal(r); chk.E(err) { + return + } + if r, err = envs.SkipToTheEnd(r); chk.E(err) { + return + } + return +} + +// ParseResponse reads a Response encoded in minified JSON and unpacks it to +// the runtime format. +func ParseResponse(b []byte) (t *Response, rem []byte, err error) { + t = NewResponse() + if rem, err = t.Unmarshal(b); chk.E(err) { + return + } + return +} diff --git a/pkg/encoders/envelopes/authenvelope/authenvelope_test.go b/pkg/encoders/envelopes/authenvelope/authenvelope_test.go new file mode 100644 index 0000000..e9fbc1a --- /dev/null +++ b/pkg/encoders/envelopes/authenvelope/authenvelope_test.go @@ -0,0 +1,86 @@ +package authenvelope + +import ( + "testing" + + "lol.mleku.dev/chk" + "next.orly.dev/pkg/crypto/p256k" + "next.orly.dev/pkg/encoders/envelopes" + "next.orly.dev/pkg/protocol/auth" + "next.orly.dev/pkg/utils" + "next.orly.dev/pkg/utils/bufpool" +) + +const relayURL = "wss://example.com" + +func TestAuth(t *testing.T) { + var err error + signer := new(p256k.Signer) + if err = signer.Generate(); chk.E(err) { + t.Fatal(err) + } + for _ = range 1000 { + b1, b2, b3, b4 := bufpool.Get(), bufpool.Get(), bufpool.Get(), bufpool.Get() + ch := auth.GenerateChallenge() + chal := Challenge{Challenge: ch} + b1 = chal.Marshal(b1) + oChal := make([]byte, len(b1)) + copy(oChal, b1) + var rem []byte + var l string + if l, b1, err = envelopes.Identify(b1); chk.E(err) { + t.Fatal(err) + } + if l != L { + t.Fatalf("invalid sentinel %s, expect %s", l, L) + } + c2 := NewChallenge() + if rem, err = c2.Unmarshal(b1); chk.E(err) { + t.Fatal(err) + } + if len(rem) != 0 { + t.Fatalf("remainder should be empty\n%s", rem) + } + if !utils.FastEqual(chal.Challenge, c2.Challenge) { + t.Fatalf( + "challenge mismatch\n%s\n%s", + chal.Challenge, c2.Challenge, + ) + } + b2 = c2.Marshal(b2) + if !utils.FastEqual(oChal, b2) { + t.Fatalf("challenge mismatch\n%s\n%s", oChal, b2) + } + resp := Response{ + Event: auth.CreateUnsigned( + signer.Pub(), ch, + relayURL, + ), + } + if err = resp.Event.Sign(signer); chk.E(err) { + t.Fatal(err) + } + b3 = resp.Marshal(b3) + oResp := make([]byte, len(b3)) + copy(oResp, b3) + if l, b3, err = envelopes.Identify(b3); chk.E(err) { + t.Fatal(err) + } + if l != L { + t.Fatalf("invalid sentinel %s, expect %s", l, L) + } + r2 := NewResponse() + if _, err = r2.Unmarshal(b3); chk.E(err) { + t.Fatal(err) + } + b4 = r2.Marshal(b4) + if !utils.FastEqual(oResp, b4) { + t.Fatalf("challenge mismatch\n%s\n%s", oResp, b4) + } + bufpool.Put(b1) + bufpool.Put(b2) + bufpool.Put(b3) + bufpool.Put(b4) + oChal, oResp = oChal[:0], oResp[:0] + } +} diff --git a/pkg/encoders/envelopes/closedenvelope/closedenvelope.go b/pkg/encoders/envelopes/closedenvelope/closedenvelope.go new file mode 100644 index 0000000..309b78f --- /dev/null +++ b/pkg/encoders/envelopes/closedenvelope/closedenvelope.go @@ -0,0 +1,100 @@ +// Package closedenvelope defines the nostr message type CLOSED which is sent +// from a relay to indicate the relay-side termination of a subscription or the +// demand for authentication associated with a subscription. +package closedenvelope + +import ( + "io" + + "lol.mleku.dev/chk" + "next.orly.dev/pkg/encoders/envelopes" + "next.orly.dev/pkg/encoders/text" + "next.orly.dev/pkg/interfaces/codec" +) + +// L is the label associated with this type of codec.Envelope. +const L = "CLOSED" + +// T is a CLOSED envelope, which is a signal that a subscription has been +// stopped on the relay side for some reason. Primarily this is for auth and can +// be for other things like rate limiting. +type T struct { + Subscription []byte + Reason []byte +} + +var _ codec.Envelope = (*T)(nil) + +// New creates an empty new T. +func New() *T { + return new(T) +} + +// NewFrom creates a new closedenvelope.T populated with subscription ID and Reason. +func NewFrom(id, msg []byte) *T { + return &T{ + Subscription: id, Reason: msg, + } +} + +// Label returns the label of a closedenvelope.T. +func (en *T) Label() string { return L } + +// ReasonString returns the Reason in the form of a string. +func (en *T) ReasonString() string { return string(en.Reason) } + +// Write the closedenvelope.T to a provided io.Writer. +func (en *T) Write(w io.Writer) (err error) { + var b []byte + b = en.Marshal(b) + _, err = w.Write(b) + return +} + +// Marshal a closedenvelope.T envelope in minified JSON, appending to a provided +// destination slice. Note that this ensures correct string escaping on the +// Reason field. +func (en *T) Marshal(dst []byte) (b []byte) { + b = dst + b = envelopes.Marshal( + b, L, + func(bst []byte) (o []byte) { + o = bst + o = append(o, '"') + o = append(o, en.Subscription...) + o = append(o, '"') + o = append(o, ',') + o = append(o, '"') + o = text.NostrEscape(o, en.Reason) + o = append(o, '"') + return + }, + ) + return +} + +// Unmarshal a closedenvelope.T from minified JSON, returning the remainder after the end +// of the envelope. Note that this ensures the Reason string is correctly +// unescaped by NIP-01 escaping rules. +func (en *T) Unmarshal(b []byte) (r []byte, err error) { + r = b + if en.Subscription, r, err = text.UnmarshalQuoted(r); chk.E(err) { + return + } + if en.Reason, r, err = text.UnmarshalQuoted(r); chk.E(err) { + return + } + if r, err = envelopes.SkipToTheEnd(r); chk.E(err) { + return + } + return +} + +// Parse reads a closedenvelope.T from minified JSON into a newly allocated closedenvelope.T. +func Parse(b []byte) (t *T, rem []byte, err error) { + t = New() + if rem, err = t.Unmarshal(b); chk.E(err) { + return + } + return +} diff --git a/pkg/encoders/envelopes/closedenvelope/closedenvelope_test.go b/pkg/encoders/envelopes/closedenvelope/closedenvelope_test.go new file mode 100644 index 0000000..faadb6a --- /dev/null +++ b/pkg/encoders/envelopes/closedenvelope/closedenvelope_test.go @@ -0,0 +1,85 @@ +package closedenvelope + +import ( + "fmt" + "math" + "testing" + + "lol.mleku.dev/chk" + "next.orly.dev/pkg/encoders/envelopes" + "next.orly.dev/pkg/utils" + "next.orly.dev/pkg/utils/bufpool" + + "lukechampine.com/frand" +) + +var messages = [][]byte{ + []byte(""), + []byte("pow: difficulty 25>=24"), + []byte("duplicate: already have this event"), + []byte("blocked: you are banned from posting here"), + []byte("blocked: please register your pubkey at https://my-expensive-realy.example.com"), + []byte("rate-limited: slow down there chief"), + []byte("invalid: event creation date is too far off from the current time"), + []byte("pow: difficulty 26 is less than 30"), + []byte("error: could not connect to the database"), +} + +func RandomMessage() []byte { + return messages[frand.Intn(len(messages)-1)] +} + +func TestMarshalUnmarshal(t *testing.T) { + var err error + for _ = range 1000 { + rb, rb1, rb2 := bufpool.Get(), bufpool.Get(), bufpool.Get() + s := []byte(fmt.Sprintf("sub:%d", frand.Intn(math.MaxInt64))) + req := NewFrom(s, RandomMessage()) + rb = req.Marshal(rb) + rb1 = append(rb1, rb...) + var rem []byte + var l string + if l, rb, err = envelopes.Identify(rb); chk.E(err) { + t.Fatal(err) + } + if l != L { + t.Fatalf("invalid sentinel %s, expect %s", l, L) + } + req2 := New() + if rem, err = req2.Unmarshal(rb); chk.E(err) { + t.Fatal(err) + } + // log.I.Ln(req2.ID) + if len(rem) > 0 { + t.Fatalf( + "unmarshal failed, remainder\n%d %s", + len(rem), rem, + ) + } + rb2 = req2.Marshal(rb2) + if !utils.FastEqual(rb1, rb2) { + if len(rb1) != len(rb2) { + t.Fatalf( + "unmarshal failed, different lengths\n%d %s\n%d %s\n", + len(rb1), rb1, len(rb2), rb2, + ) + } + for i := range rb1 { + if rb1[i] != rb2[i] { + t.Fatalf( + "unmarshal failed, difference at position %d\n%d %s\n%s\n%d %s\n%s\n", + i, len(rb1), rb1[:i], rb1[i:], len(rb2), rb2[:i], + rb2[i:], + ) + } + } + t.Fatalf( + "unmarshal failed\n%d %s\n%d %s\n", + len(rb1), rb1, len(rb2), rb2, + ) + } + bufpool.Put(rb1) + bufpool.Put(rb2) + bufpool.Put(rb) + } +} diff --git a/pkg/encoders/envelopes/closeenvelope/closeenvelope.go b/pkg/encoders/envelopes/closeenvelope/closeenvelope.go new file mode 100644 index 0000000..b523ebf --- /dev/null +++ b/pkg/encoders/envelopes/closeenvelope/closeenvelope.go @@ -0,0 +1,78 @@ +// Package closeenvelope provides the encoder for the client message CLOSE which +// is a request to terminate a subscription. +package closeenvelope + +import ( + "io" + + "lol.mleku.dev/chk" + "next.orly.dev/pkg/encoders/envelopes" + "next.orly.dev/pkg/encoders/text" + "next.orly.dev/pkg/interfaces/codec" +) + +// L is the label associated with this type of codec.Envelope. +const L = "CLOSE" + +// T is a CLOSE envelope, which is a signal from client to relay to stop a +// specified subscription. +type T struct { + ID []byte +} + +var _ codec.Envelope = (*T)(nil) + +// New creates an empty new standard formatted closeenvelope.T. +func New() *T { return new(T) } + +// NewFrom creates a new closeenvelope.T populated with subscription ID. +func NewFrom(id []byte) *T { return &T{ID: id} } + +// Label returns the label of a closeenvelope.T. +func (en *T) Label() string { return L } + +// Write the closeenvelope.T to a provided io.Writer. +func (en *T) Write(w io.Writer) (err error) { + _, err = w.Write(en.Marshal(nil)) + return +} + +// Marshal a closeenvelope.T envelope in minified JSON, appending to a provided +// destination slice. +func (en *T) Marshal(dst []byte) (b []byte) { + b = dst + b = envelopes.Marshal( + b, L, + func(bst []byte) (o []byte) { + o = bst + o = append(o, '"') + o = append(o, en.ID...) + o = append(o, '"') + return + }, + ) + return +} + +// Unmarshal a closeenvelope.T from minified JSON, returning the remainder after +// the end of the envelope. +func (en *T) Unmarshal(b []byte) (r []byte, err error) { + r = b + if en.ID, r, err = text.UnmarshalQuoted(r); chk.E(err) { + return + } + if r, err = envelopes.SkipToTheEnd(r); chk.E(err) { + return + } + return +} + +// Parse reads a CLOSE envelope from minified JSON into a newly allocated +// closeenvelope.T. +func Parse(b []byte) (t *T, rem []byte, err error) { + t = New() + if rem, err = t.Unmarshal(b); chk.E(err) { + return + } + return +} diff --git a/pkg/encoders/envelopes/closeenvelope/closeenvelope_test.go b/pkg/encoders/envelopes/closeenvelope/closeenvelope_test.go new file mode 100644 index 0000000..ca5798b --- /dev/null +++ b/pkg/encoders/envelopes/closeenvelope/closeenvelope_test.go @@ -0,0 +1,68 @@ +package closeenvelope + +import ( + "fmt" + "math" + "testing" + + "lol.mleku.dev/chk" + "lukechampine.com/frand" + "next.orly.dev/pkg/encoders/envelopes" + "next.orly.dev/pkg/utils" + "next.orly.dev/pkg/utils/bufpool" +) + +func TestMarshalUnmarshal(t *testing.T) { + var err error + for _ = range 1000 { + rb, rb1, rb2 := bufpool.Get(), bufpool.Get(), bufpool.Get() + s := []byte(fmt.Sprintf("sub:%d", frand.Intn(math.MaxInt64))) + req := NewFrom(s) + rb = req.Marshal(rb) + rb1 = append(rb1, rb...) + var rem []byte + var l string + if l, rb, err = envelopes.Identify(rb); chk.E(err) { + t.Fatal(err) + } + if l != L { + t.Fatalf("invalid sentinel %s, expect %s", l, L) + } + req2 := New() + if rem, err = req2.Unmarshal(rb); chk.E(err) { + t.Fatal(err) + } + // log.I.Ln(req2.ID) + if len(rem) > 0 { + t.Fatalf( + "unmarshal failed, remainder\n%d %s", + len(rem), rem, + ) + } + rb2 = req2.Marshal(rb2) + if !utils.FastEqual(rb1, rb2) { + if len(rb1) != len(rb2) { + t.Fatalf( + "unmarshal failed, different lengths\n%d %s\n%d %s\n", + len(rb1), rb1, len(rb2), rb2, + ) + } + for i := range rb1 { + if rb1[i] != rb2[i] { + t.Fatalf( + "unmarshal failed, difference at position %d\n%d %s\n%s\n%d %s\n%s\n", + i, len(rb1), rb1[:i], rb1[i:], len(rb2), rb2[:i], + rb2[i:], + ) + } + } + t.Fatalf( + "unmarshal failed\n%d %s\n%d %s\n", + len(rb1), rb1, len(rb2), rb2, + ) + bufpool.Put(rb1) + bufpool.Put(rb2) + bufpool.Put(rb) + } + } +} diff --git a/pkg/encoders/envelopes/doc.go b/pkg/encoders/envelopes/doc.go new file mode 100644 index 0000000..eb3a683 --- /dev/null +++ b/pkg/encoders/envelopes/doc.go @@ -0,0 +1,3 @@ +// Package envelopes provides common functions for marshaling and identifying +// nostr envelopes (JSON arrays containing protocol messages). +package envelopes diff --git a/pkg/encoders/envelopes/identify.go b/pkg/encoders/envelopes/identify.go new file mode 100644 index 0000000..435aa81 --- /dev/null +++ b/pkg/encoders/envelopes/identify.go @@ -0,0 +1,38 @@ +package envelopes + +// Identify handles determining what kind of codec.Envelope is, by the Label, +// the first step in identifying the structure of the message. This first step +// is not sufficient because the same labels are used on several codec.Envelope +// types in the nostr specification. The rest of the context is in whether this +// is a client or a relay receiving it. +func Identify(b []byte) (t string, rem []byte, err error) { + var openBrackets, openQuotes, afterQuotes bool + var label []byte + rem = b + for ; len(rem) > 0; rem = rem[1:] { + if !openBrackets && rem[0] == '[' { + openBrackets = true + } else if openBrackets { + if !openQuotes && rem[0] == '"' { + openQuotes = true + } else if afterQuotes { + // return the remainder after the comma + if rem[0] == ',' { + rem = rem[1:] + return + } + } else if openQuotes { + for i := range rem { + if rem[i] == '"' { + label = rem[:i] + rem = rem[i:] + t = string(label) + afterQuotes = true + break + } + } + } + } + } + return +} diff --git a/pkg/encoders/envelopes/process.go b/pkg/encoders/envelopes/process.go new file mode 100644 index 0000000..11d6671 --- /dev/null +++ b/pkg/encoders/envelopes/process.go @@ -0,0 +1,41 @@ +package envelopes + +import ( + "io" +) + +// Marshaller is a function signature the same as the codec.JSON Marshal but +// without the requirement of there being a full implementation or declared +// receiver variable of this interface. Used here to encapsulate one or more +// other data structures into an envelope. +type Marshaller func(dst []byte) (b []byte) + +// Marshal is a parser for dynamic typed arrays like nosttr codec.Envelope +// types. +func Marshal(dst []byte, label string, m Marshaller) (b []byte) { + b = dst + b = append(b, '[', '"') + b = append(b, label...) + b = append(b, '"', ',') + b = m(b) + b = append(b, ']') + return +} + +// SkipToTheEnd scans forward after all fields in an envelope have been read to +// find the closing bracket. +func SkipToTheEnd(dst []byte) (rem []byte, err error) { + if len(dst) == 0 { + return + } + rem = dst + // we have everything, just need to snip the end + for ; len(rem) > 0; rem = rem[1:] { + if rem[0] == ']' { + rem = rem[:0] + return + } + } + err = io.EOF + return +} diff --git a/pkg/encoders/event/canonical.go b/pkg/encoders/event/canonical.go new file mode 100644 index 0000000..283ca80 --- /dev/null +++ b/pkg/encoders/event/canonical.go @@ -0,0 +1,36 @@ +package event + +import ( + "next.orly.dev/pkg/crypto/sha256" + "next.orly.dev/pkg/encoders/hex" + "next.orly.dev/pkg/encoders/ints" + "next.orly.dev/pkg/encoders/text" +) + +// ToCanonical converts the event to the canonical encoding used to derive the +// event ID. +func (ev *E) ToCanonical(dst []byte) (b []byte) { + b = dst + b = append(b, "[0,\""...) + b = hex.EncAppend(b, ev.Pubkey) + b = append(b, "\","...) + b = ints.New(ev.CreatedAt).Marshal(nil) + b = append(b, ',') + b = ints.New(ev.Kind).Marshal(nil) + b = append(b, ',') + b = ev.Tags.Marshal(b) + b = append(b, ',') + b = text.AppendQuote(b, ev.Content, text.NostrEscape) + b = append(b, ']') + return +} + +// GetIDBytes returns the raw SHA256 hash of the canonical form of an event.E. +func (ev *E) GetIDBytes() []byte { return Hash(ev.ToCanonical(nil)) } + +// Hash is a little helper generate a hash and return a slice instead of an +// array. +func Hash(in []byte) (out []byte) { + h := sha256.Sum256(in) + return h[:] +} diff --git a/pkg/encoders/event/event.go b/pkg/encoders/event/event.go index d8300c9..0d220f9 100644 --- a/pkg/encoders/event/event.go +++ b/pkg/encoders/event/event.go @@ -94,17 +94,8 @@ func (ev *E) Free() { ev.b = nil } -// MarshalJSON marshals an event.E into a JSON byte string. -// -// Call bufpool.PutBytes(b) to return the buffer to the bufpool after use. -// -// WARNING: if json.Marshal is called in the hopes of invoking this function on -// an event, if it has <, > or * in the content or tags they are escaped into -// unicode escapes and break the event ID. Call this function directly in order -// to bypass this issue. -func (ev *E) MarshalJSON() (b []byte, err error) { - b = bufpool.Get() - b = b[:0] +func (ev *E) Marshal(dst []byte) (b []byte) { + b = dst b = append(b, '{') b = append(b, '"') b = append(b, jId...) @@ -159,10 +150,76 @@ func (ev *E) MarshalJSON() (b []byte, err error) { return } -// UnmarshalJSON unmarshalls a JSON string into an event.E. +// MarshalJSON marshals an event.E into a JSON byte string. +// +// Call bufpool.PutBytes(b) to return the buffer to the bufpool after use. +// +// WARNING: if json.Marshal is called in the hopes of invoking this function on +// an event, if it has <, > or * in the content or tags they are escaped into +// unicode escapes and break the event ID. Call this function directly in order +// to bypass this issue. +func (ev *E) MarshalJSON() (b []byte, err error) { + b = bufpool.Get() + b = ev.Marshal(b[:0]) + // b = b[:0] + // b = append(b, '{') + // b = append(b, '"') + // b = append(b, jId...) + // b = append(b, `":"`...) + // b = b[:len(b)+2*sha256.Size] + // xhex.Encode(b[len(b)-2*sha256.Size:], ev.ID) + // b = append(b, `","`...) + // b = append(b, jPubkey...) + // b = append(b, `":"`...) + // b = b[:len(b)+2*schnorr.PubKeyBytesLen] + // xhex.Encode(b[len(b)-2*schnorr.PubKeyBytesLen:], ev.Pubkey) + // b = append(b, `","`...) + // b = append(b, jCreatedAt...) + // b = append(b, `":`...) + // b = ints.New(ev.CreatedAt).Marshal(b) + // b = append(b, `,"`...) + // b = append(b, jKind...) + // b = append(b, `":`...) + // b = ints.New(ev.Kind).Marshal(b) + // b = append(b, `,"`...) + // b = append(b, jTags...) + // b = append(b, `":`...) + // if ev.Tags != nil { + // b = ev.Tags.Marshal(b) + // } + // b = append(b, `,"`...) + // b = append(b, jContent...) + // b = append(b, `":"`...) + // // it can happen the slice has insufficient capacity to hold the content AND + // // the signature at this point, because the signature encoder must have + // // sufficient capacity pre-allocated as it does not append to the buffer. + // // unlike every other encoding function up to this point. This also ensures + // // that since the bufpool defaults to 1kb, most events won't have a + // // re-allocation required, but if they do, it will be this next one, and it + // // integrates properly with the buffer pool, reducing GC pressure and + // // avoiding new heap allocations. + // if cap(b) < len(b)+len(ev.Content)+7+256+2 { + // b2 := make([]byte, len(b)+len(ev.Content)*2+7+256+2) + // copy(b2, b) + // b2 = b2[:len(b)] + // // return the old buffer to the pool for reuse. + // bufpool.PutBytes(b) + // b = b2 + // } + // b = text.NostrEscape(b, ev.Content) + // b = append(b, `","`...) + // b = append(b, jSig...) + // b = append(b, `":"`...) + // b = b[:len(b)+2*schnorr.SignatureSize] + // xhex.Encode(b[len(b)-2*schnorr.SignatureSize:], ev.Sig) + // b = append(b, `"}`...) + return +} + +// Unmarshal unmarshalls a JSON string into an event.E. // // Call ev.Free() to return the provided buffer to the bufpool afterwards. -func (ev *E) UnmarshalJSON(b []byte) (err error) { +func (ev *E) Unmarshal(b []byte) (rem []byte, err error) { key := make([]byte, 0, 9) for ; len(b) > 0; b = b[1:] { // Skip whitespace @@ -337,10 +394,7 @@ BetweenKV: log.I.F("between kv") goto eof AfterClose: - // Skip any trailing whitespace - for len(b) > 0 && isWhitespace(b[0]) { - b = b[1:] - } + rem = b return invalid: err = fmt.Errorf( @@ -353,6 +407,14 @@ eof: return } +// UnmarshalJSON unmarshalls a JSON string into an event.E. +// +// Call ev.Free() to return the provided buffer to the bufpool afterwards. +func (ev *E) UnmarshalJSON(b []byte) (err error) { + _, err = ev.Unmarshal(b) + return +} + // isWhitespace returns true if the byte is a whitespace character (space, tab, newline, carriage return). func isWhitespace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' diff --git a/pkg/encoders/event/signatures.go b/pkg/encoders/event/signatures.go new file mode 100644 index 0000000..96d2a2c --- /dev/null +++ b/pkg/encoders/event/signatures.go @@ -0,0 +1,48 @@ +package event + +import ( + "lol.mleku.dev/chk" + "lol.mleku.dev/errorf" + "lol.mleku.dev/log" + "next.orly.dev/pkg/crypto/p256k" + "next.orly.dev/pkg/interfaces/signer" + "next.orly.dev/pkg/utils" +) + +// Sign the event using the signer.I. Uses github.com/bitcoin-core/secp256k1 if +// available for much faster signatures. +// +// Note that this only populates the Pubkey, ID and Sig. The caller must +// set the CreatedAt timestamp as intended. +func (ev *E) Sign(keys signer.I) (err error) { + ev.Pubkey = keys.Pub() + ev.ID = ev.GetIDBytes() + if ev.Sig, err = keys.Sign(ev.ID); chk.E(err) { + return + } + return +} + +// Verify an event is signed by the pubkey it contains. Uses +// github.com/bitcoin-core/secp256k1 if available for faster verification. +func (ev *E) Verify() (valid bool, err error) { + keys := p256k.Signer{} + if err = keys.InitPub(ev.Pubkey); chk.E(err) { + return + } + if valid, err = keys.Verify(ev.ID, ev.Sig); chk.T(err) { + // check that this isn't because of a bogus ID + id := ev.GetIDBytes() + if !utils.FastEqual(id, ev.ID) { + log.E.Ln("event ID incorrect") + ev.ID = id + err = nil + if valid, err = keys.Verify(ev.ID, ev.Sig); chk.E(err) { + return + } + err = errorf.W("event ID incorrect but signature is valid on correct ID") + } + return + } + return +} diff --git a/pkg/encoders/kind/kind.go b/pkg/encoders/kind/kind.go index bcb03ff..07aea39 100644 --- a/pkg/encoders/kind/kind.go +++ b/pkg/encoders/kind/kind.go @@ -57,7 +57,7 @@ func (k *K) ToU64() uint64 { // Name returns the human readable string describing the semantics of the // kind.K. -func (k *K) Name() string { return GetString(k) } +func (k *K) Name() string { return GetString(k.K) } // Equal checks if func (k *K) Equal(k2 *K) bool { @@ -105,13 +105,10 @@ func (k *K) Unmarshal(b []byte) (r []byte, err error) { } // GetString returns a human-readable identifier for a kind.K. -func GetString(t *K) string { - if t == nil { - return "" - } - MapMx.Lock() - defer MapMx.Unlock() - return Map[t.K] +func GetString(t uint16) string { + MapMx.RLock() + defer MapMx.RUnlock() + return Map[t] } // IsEphemeral returns true if the event kind is an ephemeral event. (not to be @@ -340,7 +337,7 @@ var ( ParameterizedReplaceableEnd = &K{39999} ) -var MapMx sync.Mutex +var MapMx sync.RWMutex var Map = map[uint16]string{ ProfileMetadata.K: "ProfileMetadata", TextNote.K: "TextNote", diff --git a/pkg/encoders/tag/tag.go b/pkg/encoders/tag/tag.go index e9c33ae..c91436b 100644 --- a/pkg/encoders/tag/tag.go +++ b/pkg/encoders/tag/tag.go @@ -23,8 +23,18 @@ type T struct { b bufpool.B } -func New(t ...[]byte) *T { - return &T{T: t, b: bufpool.Get()} +func New(t ...any) *T { + var bs [][]byte + for _, v := range t { + if vb, ok := v.([]byte); ok { + bs = append(bs, vb) + } else if vs, ok := v.(string); ok { + bs = append(bs, []byte(vs)) + } else { + panic("programmer error: type of tag element is not []byte or string") + } + } + return &T{T: bs, b: bufpool.Get()} } func NewWithCap(c int) *T { @@ -45,8 +55,6 @@ func (t *T) Less(i, j int) bool { func (t *T) Swap(i, j int) { t.T[i], t.T[j] = t.T[j], t.T[i] } // Marshal encodes a tag.T as standard minified JSON array of strings. -// -// Call bufpool.PutBytes(b) to return the buffer to the bufpool after use. func (t *T) Marshal(dst []byte) (b []byte) { dst = append(dst, '[') for i, s := range t.T { @@ -63,15 +71,21 @@ func (t *T) Marshal(dst []byte) (b []byte) { // // Warning: this will mangle the output if the tag fields contain <, > or & // characters. do not use json.Marshal in the hopes of rendering tags verbatim -// in an event as you will have a bad time. +// in an event as you will have a bad time. Use the json.Marshal function in the +// pkg/encoders/json package instead, this has a fork of the json library that +// disables html escaping for json.Marshal. +// +// Call bufpool.PutBytes(b) to return the buffer to the bufpool after use. func (t *T) MarshalJSON() (b []byte, err error) { - b = t.Marshal(nil) + b = bufpool.Get() + b = t.Marshal(b) return } // Unmarshal decodes a standard minified JSON array of strings to a tags.T. // -// Call bufpool.PutBytes(b) to return the buffer to the bufpool after use. +// Call bufpool.PutBytes(b) to return the buffer to the bufpool after use if it +// was originally created using bufpool.Get(). func (t *T) Unmarshal(b []byte) (r []byte, err error) { var inQuotes, openedBracket bool var quoteStart int @@ -101,3 +115,24 @@ func (t *T) UnmarshalJSON(b []byte) (err error) { _, err = t.Unmarshal(b) return } + +func (t *T) Key() (key []byte) { + if len(t.T) > Key { + return t.T[Key] + } + return +} + +func (t *T) Value() (key []byte) { + if len(t.T) > Value { + return t.T[Value] + } + return +} + +func (t *T) Relay() (key []byte) { + if len(t.T) > Relay { + return t.T[Relay] + } + return +} diff --git a/pkg/encoders/tag/tags.go b/pkg/encoders/tag/tags.go index 8c871c8..12d2deb 100644 --- a/pkg/encoders/tag/tags.go +++ b/pkg/encoders/tag/tags.go @@ -4,6 +4,7 @@ import ( "bytes" "lol.mleku.dev/chk" + "next.orly.dev/pkg/utils" "next.orly.dev/pkg/utils/bufpool" ) @@ -11,6 +12,12 @@ import ( // no uniqueness constraint (not a set). type S []*T +func NewS(t ...*T) (s *S) { + s = new(S) + *s = append(*s, t...) + return +} + func NewSWithCap(c int) (s *S) { ss := make([]*T, 0, c) return (*S)(&ss) @@ -31,6 +38,10 @@ func (s *S) Swap(i, j int) { panic("implement me") } +func (s *S) Append(t ...*T) { + *s = append(*s, t...) +} + // MarshalJSON encodes a tags.T appended to a provided byte slice in JSON form. // // Call bufpool.PutBytes(b) to return the buffer to the bufpool after use. @@ -110,3 +121,13 @@ func (s *S) Unmarshal(b []byte) (r []byte, err error) { } return } + +// GetFirst returns the first tag.T that has the same Key as t. +func (s *S) GetFirst(t []byte) (first *T) { + for _, tt := range *s { + if utils.FastEqual(tt.T[0], t) { + return tt + } + } + return +} diff --git a/pkg/interfaces/codec/codec.go b/pkg/interfaces/codec/codec.go new file mode 100644 index 0000000..1490b7b --- /dev/null +++ b/pkg/interfaces/codec/codec.go @@ -0,0 +1,47 @@ +package codec + +import ( + "io" +) + +type I interface { + MarshalWrite(w io.Writer) (err error) + UnmarshalRead(r io.Reader) (err error) +} + +// Envelope is an interface for the nostr "envelope" message formats, a JSON +// array with the first field an upper case string that provides type +// information, in combination with the context of the side sending it (relay or +// client). +type Envelope interface { + // Label returns the (uppercase) string that signifies the type of message. + Label() string + // Write outputs the envelope to an io.Writer + Write(w io.Writer) (err error) + // JSON is a somewhat simplified version of the + // json.Marshaler/json.Unmarshaler that has no error for the Marshal side of + // the operation. + JSON +} + +// JSON is a somewhat simplified version of the json.Marshaler/json.Unmarshaler +// that has no error for the Marshal side of the operation. +type JSON interface { + // Marshal converts the data of the type into JSON, appending it to the provided + // slice and returning the extended slice. + Marshal(dst []byte) (b []byte) + // Unmarshal decodes a JSON form of a type back into the runtime form, and + // returns whatever remains after the type has been decoded out. + Unmarshal(b []byte) (r []byte, err error) +} + +// Binary is a similarly simplified form of the stdlib binary Marshal/Unmarshal +// server. Same as JSON it does not have an error for the MarshalBinary. +type Binary interface { + // MarshalBinary converts the data of the type into binary form, appending + // it to the provided slice. + MarshalBinary(dst []byte) (b []byte) + // UnmarshalBinary decodes a binary form of a type back into the runtime + // form, and returns whatever remains after the type has been decoded out. + UnmarshalBinary(b []byte) (r []byte, err error) +} diff --git a/pkg/protocol/auth/nip42.go b/pkg/protocol/auth/nip42.go new file mode 100644 index 0000000..a01471b --- /dev/null +++ b/pkg/protocol/auth/nip42.go @@ -0,0 +1,126 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "net/url" + "strings" + "time" + + "lol.mleku.dev/chk" + "lol.mleku.dev/errorf" + "next.orly.dev/pkg/encoders/event" + "next.orly.dev/pkg/encoders/kind" + "next.orly.dev/pkg/encoders/tag" + "next.orly.dev/pkg/utils" +) + +// GenerateChallenge creates a reasonable, 16-byte base64 challenge string +func GenerateChallenge() (b []byte) { + bb := make([]byte, 12) + b = make([]byte, 16) + _, _ = rand.Read(bb) + base64.URLEncoding.Encode(b, bb) + return +} + +// CreateUnsigned creates an event which should be sent via an "AUTH" command. +// If the authentication succeeds, the user will be authenticated as a pubkey. +func CreateUnsigned(pubkey, challenge []byte, relayURL string) (ev *event.E) { + return &event.E{ + Pubkey: pubkey, + CreatedAt: time.Now().Unix(), + Kind: kind.ClientAuthentication.K, + Tags: tag.NewS( + tag.New("relay", relayURL), + tag.New("challenge", string(challenge)), + ), + } +} + +// helper function for ValidateAuthEvent. +func parseURL(input string) (*url.URL, error) { + return url.Parse( + strings.ToLower( + strings.TrimSuffix(input, "/"), + ), + ) +} + +var ( + // ChallengeTag is the tag for the challenge in a NIP-42 auth event + // (prevents relay attacks). + ChallengeTag = []byte("challenge") + // RelayTag is the relay tag for a NIP-42 auth event (prevents cross-server + // attacks). + RelayTag = []byte("relay") +) + +// Validate checks whether an event is a valid NIP-42 event for a given +// challenge and relayURL. The result of the validation is encoded in the ok +// bool. +func Validate(evt *event.E, challenge []byte, relayURL string) ( + ok bool, err error, +) { + if evt.Kind != kind.ClientAuthentication.K { + err = errorf.E( + "event incorrect kind for auth: %d %s", + evt.Kind, kind.GetString(evt.Kind), + ) + return + } + if evt.Tags.GetFirst(ChallengeTag) == nil { + err = errorf.E("challenge tag missing from auth response") + return + } + if !utils.FastEqual(challenge, evt.Tags.GetFirst(ChallengeTag).Value()) { + err = errorf.E("challenge tag incorrect from auth response") + return + } + var expected, found *url.URL + if expected, err = parseURL(relayURL); chk.D(err) { + return + } + r := evt.Tags. + GetFirst(RelayTag).Value() + if len(r) == 0 { + err = errorf.E("relay tag missing from auth response") + return + } + if found, err = parseURL(string(r)); chk.D(err) { + err = errorf.E("error parsing relay url: %s", err) + return + } + if expected.Scheme != found.Scheme { + err = errorf.E( + "HTTP Scheme incorrect: expected '%s' got '%s", + expected.Scheme, found.Scheme, + ) + return + } + if expected.Host != found.Host { + err = errorf.E( + "HTTP Host incorrect: expected '%s' got '%s", + expected.Host, found.Host, + ) + return + } + if expected.Path != found.Path { + err = errorf.E( + "HTTP Path incorrect: expected '%s' got '%s", + expected.Path, found.Path, + ) + return + } + + now := time.Now().Unix() + ca := evt.CreatedAt + if ca > now+10*60 || ca < now-10*60 { + err = errorf.E( + "auth event more than 10 minutes before or after current time", + ) + return + } + // save for last, as it is the most expensive operation + return evt.Verify() +} diff --git a/pkg/protocol/auth/nip42_test.go b/pkg/protocol/auth/nip42_test.go new file mode 100644 index 0000000..f74bcba --- /dev/null +++ b/pkg/protocol/auth/nip42_test.go @@ -0,0 +1,34 @@ +package auth + +import ( + "testing" + + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + "next.orly.dev/pkg/crypto/p256k" +) + +func TestCreateUnsigned(t *testing.T) { + var err error + signer := new(p256k.Signer) + if err = signer.Generate(); chk.E(err) { + t.Fatal(err) + } + var ok bool + const relayURL = "wss://example.com" + for range 100 { + challenge := GenerateChallenge() + ev := CreateUnsigned(signer.Pub(), challenge, relayURL) + if err = ev.Sign(signer); chk.E(err) { + t.Fatal(err) + } + log.I.S(ev) + if ok, err = Validate(ev, challenge, relayURL); chk.E(err) { + t.Fatal(err) + } + if !ok { + bb := ev.Marshal(nil) + t.Fatalf("failed to validate auth event\n%s", bb) + } + } +}