diff --git a/README.md b/README.md index d4bee2b..c2f0aa2 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,46 @@ for _, url := range []string{"wss://nostr.zebedee.cloud", "wss://nostr-pub.wello } ``` +### Authenticating with NIP-42 + +For this section, the user needs access to a relay implementing NIP-42. +E.g., https://github.com/fiatjaf/relayer with a relay implementing the relayer.Auther interface. + +``` go +func main() { + url := "ws://localhost:7447" + + // Once the connection is initiated the server will send "AUTH" with the challenge string. + relay, err := nostr.RelayConnect(context.Background(), url) + if err != nil { + panic(err) + } + + // Initialize test user. + sk := nostr.GeneratePrivateKey() + pub, _ := nostr.GetPublicKey(sk) + npub, _ := nip19.EncodePublicKey(pub) + + // Relay.Challenges channel will receive the "AUTH" command. + challenge := <-relay.Challenges + + // Create the auth event to send back. + // The user will be authenticated as pub. + event := nip42.CreateUnsignedAuthEvent(challenge, pub, url) + event.Sign(sk) + + // Set-up context with 3 second time out. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // Send the event by calling relay.Auth. + // Returned status is either success, fail, or sent (if no reply given in the 3 second timeout). + auth_status := relay.Auth(ctx, event) + + fmt.Printf("authenticated as %s: %s\n", npub, auth_status) +} +``` + ### Example script ``` diff --git a/example/example.go b/example/example.go index 1a48f21..3682e34 100644 --- a/example/example.go +++ b/example/example.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "strings" "time" @@ -13,7 +14,7 @@ import ( ) func main() { - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) // connect to relay url := "wss://nostr.zebedee.cloud" @@ -58,7 +59,7 @@ func main() { go func() { <-sub.EndOfStoredEvents - sub.Unsub() + cancel() }() for ev := range sub.Events { evs = append(evs, ev) @@ -106,19 +107,14 @@ func main() { fmt.Fprintln(os.Stderr, "enter content of note, ending with an empty newline:") for { if n, err := reader.Read(b[:]); err == nil { - new_line := strings.TrimSpace(fmt.Sprintf("%s", b[:n])) - if new_line == "" { - break - } else if content == "" { - content = new_line - } else { - content = fmt.Sprintf("%s\n%s", content, new_line) - } + content = fmt.Sprintf("%s%s", content, fmt.Sprintf("%s", b[:n])) + } else if err == io.EOF { + break } else { panic(err) } } - ev.Content = content + ev.Content = strings.TrimSpace(content) ev.Sign(sk) for _, url := range []string{"wss://nostr.zebedee.cloud"} { ctx := context.WithValue(context.Background(), "url", url) diff --git a/nip42/nip42.go b/nip42/nip42.go index a161e30..8cdae9f 100644 --- a/nip42/nip42.go +++ b/nip42/nip42.go @@ -7,6 +7,23 @@ import ( "github.com/nbd-wtf/go-nostr" ) +// CreateUnsignedAuthEvent creates an event which should be sent via an "AUTH" command. +// If the authentication succeeds, the user will be authenticated as pubkey. +func CreateUnsignedAuthEvent(challenge, pubkey, relayURL string) nostr.Event { + return nostr.Event{ + PubKey: pubkey, + CreatedAt: time.Now(), + Kind: 22242, + Tags: nostr.Tags{ + nostr.Tag{"relay", relayURL}, + nostr.Tag{"challenge", challenge}, + }, + Content: "", + } +} + +// ValidateAuthEvent checks whether event is a valid NIP-42 event for given challenge and relayURL. +// The result of the validation is encoded in the ok bool. func ValidateAuthEvent(event *nostr.Event, challenge string, relayURL string) (pubkey string, ok bool) { if ok, _ := event.CheckSignature(); ok == false { return "", false diff --git a/relay.go b/relay.go index 651d874..1bc5e33 100644 --- a/relay.go +++ b/relay.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "log" + "sync" "time" s "github.com/SaveTheRbtz/generic-sync-map-go" @@ -40,15 +41,16 @@ type Relay struct { Connection *Connection subscriptions s.MapOf[string, *Subscription] + Challenges chan string // NIP-42 Challenges Notices chan string ConnectionError chan error okCallbacks s.MapOf[string, func(bool)] } -// RelayConnect returns a relay object connected to url -// Once successfully connected, cancelling ctx has no effect -// To close the connection, call r.Close() +// RelayConnect returns a relay object connected to url. +// Once successfully connected, cancelling ctx has no effect. +// To close the connection, call r.Close(). func RelayConnect(ctx context.Context, url string) (*Relay, error) { r := &Relay{URL: NormalizeURL(url)} err := r.Connect(ctx) @@ -80,6 +82,7 @@ func (r *Relay) Connect(ctx context.Context) error { return fmt.Errorf("error opening websocket to '%s': %w", r.URL, err) } + r.Challenges = make(chan string) r.Notices = make(chan string) r.ConnectionError = make(chan error) @@ -121,6 +124,12 @@ func (r *Relay) Connect(ctx context.Context) error { var content string json.Unmarshal(jsonMessage[1], &content) r.Notices <- content + case "AUTH": + var challenge string + json.Unmarshal(jsonMessage[1], &challenge) + go func() { + r.Challenges <- challenge + }() case "EVENT": if len(jsonMessage) < 3 { continue @@ -185,8 +194,8 @@ func (r *Relay) Connect(ctx context.Context) error { return nil } -// Publish sends an "EVENT" command to the relay r as in NIP-01 -// status can be: success, failed, or sent (no response from relay before ctx times out) +// Publish sends an "EVENT" command to the relay r as in NIP-01. +// Status can be: success, failed, or sent (no response from relay before ctx times out). func (r *Relay) Publish(ctx context.Context, event Event) Status { status := PublishStatusFailed @@ -242,9 +251,62 @@ func (r *Relay) Publish(ctx context.Context, event Event) Status { } } -// Subscribe sends a "REQ" command to the relay r as in NIP-01 -// Events are returned through the channel sub.Events -// the subscription is closed when context ctx is cancelled ("CLOSE" in NIP-01) +// Auth sends an "AUTH" command client -> relay as in NIP-42. +// Status can be: success, failed, or sent (no response from relay before ctx times out). +func (r *Relay) Auth(ctx context.Context, event Event) Status { + status := PublishStatusFailed + + // data races on status variable without this mutex + var mu sync.Mutex + + if _, ok := ctx.Deadline(); !ok { + // if no timeout is set, force it to 3 seconds + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 3*time.Second) + defer cancel() + } + + // make it cancellable so we can stop everything upon receiving an "OK" + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + defer cancel() + + // listen for an OK callback + okCallback := func(ok bool) { + mu.Lock() + if ok { + status = PublishStatusSucceeded + } else { + status = PublishStatusFailed + } + mu.Unlock() + cancel() + } + r.okCallbacks.Store(event.ID, okCallback) + defer r.okCallbacks.Delete(event.ID) + + // send AUTH + if err := r.Connection.WriteJSON([]interface{}{"AUTH", event}); err != nil { + // status will be "failed" + return status + } else { + // use mu.Lock() just in case the okCallback got called, extremely unlikely. + mu.Lock() + status = PublishStatusSent + mu.Unlock() + } + // the context either times out, and the status is "sent" + // or the okCallback is called and the status is set to "succeeded" or "failed" + // NIP-42 does not mandate an "OK" reply to an "AUTH" message + <-ctx.Done() + mu.Lock() + defer mu.Unlock() + return status +} + +// Subscribe sends a "REQ" command to the relay r as in NIP-01. +// Events are returned through the channel sub.Events. +// The subscription is closed when context ctx is cancelled ("CLOSE" in NIP-01). func (r *Relay) Subscribe(ctx context.Context, filters Filters) *Subscription { if r.Connection == nil { panic(fmt.Errorf("must call .Connect() first before calling .Subscribe()")) diff --git a/subscription.go b/subscription.go index 55d294f..80eba4c 100644 --- a/subscription.go +++ b/subscription.go @@ -24,8 +24,8 @@ type EventMessage struct { Relay string } -// Unsub closes the subscription, sending "CLOSE" to relay as in NIP-01 -// Unsub() also closes the channel sub.Events +// Unsub closes the subscription, sending "CLOSE" to relay as in NIP-01. +// Unsub() also closes the channel sub.Events. func (sub *Subscription) Unsub() { sub.mutex.Lock() defer sub.mutex.Unlock() @@ -37,14 +37,14 @@ func (sub *Subscription) Unsub() { sub.stopped = true } -// Sub sets sub.Filters and then calls sub.Fire(ctx) +// Sub sets sub.Filters and then calls sub.Fire(ctx). func (sub *Subscription) Sub(ctx context.Context, filters Filters) { sub.Filters = filters sub.Fire(ctx) } -// Fire sends the "REQ" command to the relay -// when ctx is cancelled, sub.Unsub() is called, closing the subscription +// Fire sends the "REQ" command to the relay. +// When ctx is cancelled, sub.Unsub() is called, closing the subscription. func (sub *Subscription) Fire(ctx context.Context) { message := []interface{}{"REQ", sub.id} for _, filter := range sub.Filters {