From 60359110d71bee29fda496b792fe9ec538a01683 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 6 Feb 2024 00:45:36 -0300 Subject: [PATCH] nip46: client. --- nip46/client.go | 180 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 nip46/client.go diff --git a/nip46/client.go b/nip46/client.go new file mode 100644 index 0000000..f40dbf2 --- /dev/null +++ b/nip46/client.go @@ -0,0 +1,180 @@ +package nip46 + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + "sync/atomic" + + "github.com/mailru/easyjson" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip04" + "github.com/puzpuzpuz/xsync/v3" +) + +type BunkerClient struct { + serial atomic.Uint64 + clientSecretKey string + pool *nostr.SimplePool + target string + relays []string + sharedSecret []byte + listeners *xsync.MapOf[string, chan Response] + + // memoized + getPublicKeyResponse string +} + +// ConnectBunker establishes an RPC connection to a NIP-46 signer using the relays and secret provided in the bunkerURL. +// pool can be passed to reuse an existing pool, otherwise a new pool will be created. +func ConnectBunker( + ctx context.Context, + clientSecretKey string, + bunkerURL string, + pool *nostr.SimplePool, +) (*BunkerClient, error) { + parsed, err := url.Parse(bunkerURL) + if err != nil { + return nil, fmt.Errorf("invalid url: %w", err) + } + + if parsed.Scheme != "bunker" { + return nil, fmt.Errorf("wrong scheme '%s', must be bunker://", parsed.Scheme) + } + + target := parsed.Host + if !nostr.IsValidPublicKey(target) { + return nil, fmt.Errorf("'%s' is not a valid public key hex", target) + } + + secret := parsed.Query().Get("secret") + relays := parsed.Query()["relay"] + + if pool == nil { + pool = nostr.NewSimplePool(ctx) + } + + shared, err := nip04.ComputeSharedSecret(target, clientSecretKey) + if err != nil { + return nil, fmt.Errorf("failed to compute shared secret: %w", err) + } + + clientPubKey, _ := nostr.GetPublicKey(clientSecretKey) + bunker := &BunkerClient{ + clientSecretKey: clientSecretKey, + pool: pool, + target: target, + relays: relays, + sharedSecret: shared, + listeners: xsync.NewMapOf[string, chan Response](), + } + + go func() { + events := pool.SubMany(ctx, relays, nostr.Filters{ + { + Tags: nostr.TagMap{"p": []string{clientPubKey}}, + Kinds: []int{nostr.KindNostrConnect}, + }, + }) + for ie := range events { + if ie.Kind != nostr.KindNostrConnect { + continue + } + + var resp Response + plain, err := nip04.Decrypt(ie.Content, shared) + if err != nil { + continue + } + + err = json.Unmarshal([]byte(plain), &resp) + if err != nil { + continue + } + + if dispatcher, ok := bunker.listeners.Load(resp.ID); ok { + dispatcher <- resp + } + } + }() + + ourPubkey, _ := nostr.GetPublicKey(clientSecretKey) + _, err = bunker.RPC(ctx, "connect", []string{ourPubkey, secret}) + return bunker, err +} + +func (bunker *BunkerClient) Ping(ctx context.Context) error { + _, err := bunker.RPC(ctx, "ping", []string{}) + if err != nil { + return err + } + return nil +} + +func (bunker *BunkerClient) GetPublicKey(ctx context.Context) (string, error) { + if bunker.getPublicKeyResponse != "" { + return bunker.getPublicKeyResponse, nil + } + resp, err := bunker.RPC(ctx, "get_public_key", []string{}) + bunker.getPublicKeyResponse = resp + return resp, err +} + +func (bunker *BunkerClient) SignEvent(ctx context.Context, evt *nostr.Event) error { + resp, err := bunker.RPC(ctx, "sign_event", []string{evt.String()}) + if err == nil { + err = easyjson.Unmarshal([]byte(resp), evt) + } + return err +} + +func (bunker *BunkerClient) RPC(ctx context.Context, method string, params []string) (string, error) { + id := strconv.FormatUint(bunker.serial.Add(1), 10) + req, err := json.Marshal(Request{ + ID: id, + Method: method, + Params: params, + }) + if err != nil { + return "", err + } + + content, err := nip04.Encrypt(string(req), bunker.sharedSecret) + if err != nil { + return "", fmt.Errorf("error encrypting request: %w", err) + } + + evt := nostr.Event{ + Content: content, + CreatedAt: nostr.Now(), + Kind: nostr.KindNostrConnect, + Tags: nostr.Tags{{"p", bunker.target}}, + } + if err := evt.Sign(bunker.clientSecretKey); err != nil { + return "", fmt.Errorf("failed to sign request event: %w", err) + } + + respWaiter := make(chan Response) + bunker.listeners.Store(id, respWaiter) + + hasWorked := false + for _, r := range bunker.relays { + relay, err := bunker.pool.EnsureRelay(r) + if err == nil { + hasWorked = true + } + relay.Publish(ctx, evt) + } + if !hasWorked { + return "", fmt.Errorf("couldn't connect to any relay") + } + + resp := <-respWaiter + if resp.Error != "" { + return "", fmt.Errorf("response error: %s", resp.Error) + } + + return resp.Result, nil +}