commit 32c21eb6523d5381e35bc69838baff8d98c4553c Author: fiatjaf Date: Sun Jan 31 11:05:09 2021 -0300 basic event and relaypool. diff --git a/event/event.go b/event/event.go new file mode 100644 index 0000000..28dc62c --- /dev/null +++ b/event/event.go @@ -0,0 +1,150 @@ +package event + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + + "github.com/fiatjaf/bip340" +) + +const ( + KindSetMetadata uint8 = 0 + KindTextNote uint8 = 1 + KindRecommendServer uint8 = 2 + KindContactList uint8 = 3 +) + +type Event struct { + ID string `json:"id"` // it's the hash of the serialized event + + PubKey string `json:"pubkey"` + CreatedAt uint32 `json:"created_at"` + + Kind uint8 `json:"kind"` + + Tags Tags `json:"tags"` + Content string `json:"content"` + Sig string `json:"sig"` +} + +type Tags []Tag + +func (t Tags) Scan(src interface{}) error { + var jtags []byte = make([]byte, 0) + + switch v := src.(type) { + case []byte: + jtags = v + case string: + jtags = []byte(v) + default: + return errors.New("couldn't scan tags, it's not a json string") + } + + json.Unmarshal(jtags, t) + return nil +} + +type Tag []interface{} + +// Serialize outputs a byte array that can be hashed/signed to identify/authenticate +func (evt *Event) Serialize() []byte { + // the serialization process is just putting everything into a JSON array + // so the order is kept + arr := make([]interface{}, 6) + + // version: 0 + arr[0] = 0 + + // pubkey + arr[1] = evt.PubKey + + // created_at + arr[2] = int64(evt.CreatedAt) + + // kind + arr[3] = int64(evt.Kind) + + // tags + if evt.Tags != nil { + arr[4] = evt.Tags + } else { + arr[4] = make([]bool, 0) + } + + // content + arr[5] = evt.Content + + serialized, _ := json.Marshal(arr) + return serialized +} + +// CheckSignature checks if the signature is valid for the id +// (which is a hash of the serialized event content). +// returns an error if the signature itself is invalid. +func (evt Event) CheckSignature() (bool, error) { + // read and check pubkey + pubkeyb, err := hex.DecodeString(evt.PubKey) + if err != nil { + return false, err + } + if len(pubkeyb) != 32 { + return false, fmt.Errorf("pubkey must be 32 bytes, not %d", len(pubkeyb)) + } + + // check tags + for _, tag := range evt.Tags { + for _, item := range tag { + switch item.(type) { + case string, int64, float64, int, bool: + // fine + default: + // not fine + return false, fmt.Errorf("tag contains an invalid value %v", item) + } + } + } + + sig, err := hex.DecodeString(evt.Sig) + if err != nil { + return false, fmt.Errorf("signature is invalid hex: %w", err) + } + if len(sig) != 64 { + return false, fmt.Errorf("signature must be 64 bytes, not %d", len(sig)) + } + + var p [32]byte + copy(p[:], pubkeyb) + + var s [64]byte + copy(s[:], sig) + + h := sha256.Sum256(evt.Serialize()) + + return bip340.Verify(p, h, s) +} + +// Sign signs an event with a given privateKey +func (evt Event) Sign(privateKey string) error { + h := sha256.Sum256(evt.Serialize()) + s, _ := new(big.Int).SetString(privateKey, 16) + + if s == nil { + return errors.New("invalid private key " + privateKey) + } + + aux := make([]byte, 32) + rand.Read(aux) + sig, err := bip340.Sign(s, h, aux) + if err != nil { + return err + } + + evt.Sig = hex.EncodeToString(sig[:]) + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d730a1e --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/fiatjaf/go-nostr + +go 1.15 + +require ( + github.com/fiatjaf/bip340 v1.0.0 + github.com/gorilla/websocket v1.4.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5e75145 --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.0.0-20190109040709-5bda5314ca95 h1:bmv+LE3sbjb/M06u2DBi92imeKj7KnCUBOvyZYqI8d8= +github.com/btcsuite/btcd v0.0.0-20190109040709-5bda5314ca95/go.mod h1:d3C0AkH6BRcvO8T0UEPu53cnw4IbV63x1bEjildYhO0= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20180706230648-ab6388e0c60a/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v0.0.0-20190112041146-bf1e1be93589/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/fiatjaf/bip340 v1.0.0 h1:mpwbm+0KC9BXB/7/pnac4e4N1TiuppyEVXxtVAXj75k= +github.com/fiatjaf/bip340 v1.0.0/go.mod h1:MxAz+5FQUTW4OT2gnCBC6Our486wmqf72ykZIrh7+is= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kkdai/bstream v0.0.0-20181106074824-b3251f7901ec/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/relaypool/relaypool.go b/relaypool/relaypool.go new file mode 100644 index 0000000..9c5bee8 --- /dev/null +++ b/relaypool/relaypool.go @@ -0,0 +1,243 @@ +package relaypool + +import ( + "encoding/json" + "errors" + "log" + + "github.com/fiatjaf/go-nostr/event" + nostrutils "github.com/fiatjaf/go-nostr/utils" + "github.com/gorilla/websocket" +) + +type RelayPool struct { + SecretKey *string + + Relays map[string]Policy + websockets map[string]*websocket.Conn + + Events chan *EventMessage + Notices chan *NoticeMessage + + SubscribedKeys []string + SubscribedEvents []string +} + +type Policy struct { + SimplePolicy + ReadSpecific map[string]SimplePolicy +} + +type SimplePolicy struct { + Read bool + Write bool +} + +type EventMessage struct { + Event event.Event + Context byte + Relay string +} + +func (em *EventMessage) UnmarshalJSON(b []byte) error { + var temp []json.RawMessage + if err := json.Unmarshal(b, temp); err != nil { + return err + } + if len(temp) < 2 { + return errors.New("message is not an array of 2 or more") + } + if err := json.Unmarshal(temp[0], em.Event); err != nil { + return err + } + var context string + if err := json.Unmarshal(temp[1], &context); err != nil { + return err + } + em.Context = context[0] + return nil +} + +type NoticeMessage struct { + Message string + Relay string +} + +func (nm *NoticeMessage) UnmarshalJSON(b []byte) error { + var temp []json.RawMessage + if err := json.Unmarshal(b, temp); err != nil { + return err + } + if len(temp) < 2 { + return errors.New("message is not an array of 2 or more") + } + var tag string + if err := json.Unmarshal(temp[0], &tag); err != nil { + return err + } + if tag != "notice" { + return errors.New("tag is not 'notice'") + } + + if err := json.Unmarshal(temp[1], &nm.Message); err != nil { + return err + } + return nil +} + +// New creates a new RelayPool with no relays in it +func New() *RelayPool { + return &RelayPool{ + Relays: make(map[string]Policy), + websockets: make(map[string]*websocket.Conn), + + Events: make(chan *EventMessage), + Notices: make(chan *NoticeMessage), + } +} + +// Add adds a new relay to the pool, if policy is nil, it will be a simple +// read+write policy. +func (r *RelayPool) Add(url string, policy *Policy) { + if policy == nil { + policy = &Policy{SimplePolicy: SimplePolicy{Read: true, Write: true}} + } + + nm := nostrutils.NormalizeURL(url) + if nm == "" { + return + } + + r.Relays[nm] = *policy + conn, _, err := websocket.DefaultDialer.Dial(nostrutils.WebsocketURL(url), nil) + if err != nil { + return + } + + defer r.Remove(nm) + done := make(chan struct{}) + + r.websockets[nm] = conn + + go func() { + defer close(done) + for { + typ, message, err := conn.ReadMessage() + if err != nil { + log.Println("read error: ", err) + return + } + if typ == websocket.PingMessage { + conn.WriteMessage(websocket.PongMessage, nil) + } + + if typ != websocket.TextMessage || len(message) == 0 || message[0] != '[' { + continue + } + + var noticeMessage *NoticeMessage + var eventMessage *EventMessage + err = json.Unmarshal(message, eventMessage) + if err == nil { + eventMessage.Relay = nm + r.Events <- eventMessage + } else { + err = json.Unmarshal(message, noticeMessage) + if err == nil { + noticeMessage.Relay = nm + r.Notices <- noticeMessage + } else { + continue + } + } + } + }() +} + +// Remove removes a relay from the pool. +func (r *RelayPool) Remove(url string) { + nm := nostrutils.NormalizeURL(url) + if conn, ok := r.websockets[nm]; ok { + conn.Close() + } + delete(r.Relays, nm) + delete(r.websockets, nm) +} + +func (r *RelayPool) SubKey(key string) { + for _, conn := range r.websockets { + conn.WriteMessage(websocket.TextMessage, []byte("sub-key:"+key)) + } +} + +func (r *RelayPool) UnsubKey(key string) { + for _, conn := range r.websockets { + conn.WriteMessage(websocket.TextMessage, []byte("unsub-key:"+key)) + } +} + +func (r *RelayPool) SubEvent(id string) { + for _, conn := range r.websockets { + conn.WriteMessage(websocket.TextMessage, []byte("sub-event:"+id)) + } +} + +func (r *RelayPool) ReqFeed(opts map[string]interface{}) { + var sopts string + if opts == nil { + sopts = "{}" + } else { + jopts, _ := json.Marshal(opts) + sopts = string(jopts) + } + + for _, conn := range r.websockets { + conn.WriteMessage(websocket.TextMessage, []byte("req-feed:"+sopts)) + } +} + +func (r *RelayPool) ReqEvent(id string, opts map[string]interface{}) { + if opts == nil { + opts = make(map[string]interface{}) + } + opts["id"] = id + + jopts, _ := json.Marshal(opts) + sopts := string(jopts) + + for _, conn := range r.websockets { + conn.WriteMessage(websocket.TextMessage, []byte("req-event:"+sopts)) + } +} + +func (r *RelayPool) ReqKey(key string, opts map[string]interface{}) { + if opts == nil { + opts = make(map[string]interface{}) + } + opts["key"] = key + + jopts, _ := json.Marshal(opts) + sopts := string(jopts) + + for _, conn := range r.websockets { + conn.WriteMessage(websocket.TextMessage, []byte("req-key:"+sopts)) + } +} + +func (r *RelayPool) PublishEvent(evt *event.Event) error { + if r.SecretKey == nil && evt.Sig == "" { + return errors.New("PublishEvent needs either a signed event to publish or to have been configured with a .SecretKey.") + } + + if evt.Sig == "" { + evt.Sign(*r.SecretKey) + } + + jevt, _ := json.Marshal(evt) + + for _, conn := range r.websockets { + conn.WriteMessage(websocket.TextMessage, jevt) + } + + return nil +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..cace5a3 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,32 @@ +package nostrutils + +import ( + "net/url" + "strings" +) + +func NormalizeURL(u string) string { + if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "ws") { + u = "ws://" + u + } + p, err := url.Parse(u) + if err != nil { + return "" + } + + if strings.HasSuffix(p.RawPath, "/") { + p.RawPath = p.RawPath[0 : len(p.RawPath)-1] + } + + if strings.HasSuffix(p.RawPath, "/ws") { + p.RawPath = p.RawPath[0 : len(p.RawPath)-3] + } + + return p.String() +} + +func WebsocketURL(u string) string { + p, _ := url.Parse(NormalizeURL(u)) + p.RawPath += "/ws" + return p.String() +}