diff --git a/nip46/nostrconnect.go b/nip46/nostrconnect.go new file mode 100644 index 0000000..2c1821c --- /dev/null +++ b/nip46/nostrconnect.go @@ -0,0 +1,130 @@ +package nip46 + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + mrand "math/rand" + "net/url" + "strconv" + "strings" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/nip44" + "github.com/puzpuzpuz/xsync/v3" +) + +var NoConnectionReceived = errors.New("relay connections ended without a bunker connection established") + +// GenerateNostrConnectURL generates a nostrconnect:// URL that can be passed to the remote-signer +// The remote-signer will then send a connect response to the client-pubkey via the specified relays +func GenerateNostrConnectURL( + ctx context.Context, + clientSecretKey [32]byte, + relayURLs []string, + permissions []string, + clientName, clientURL, clientImage string, +) (string, error) { + if len(relayURLs) == 0 { + return "", fmt.Errorf("at least one relay URL is required") + } + + // generate random secret + secretBytes := make([]byte, 16) + if _, err := rand.Read(secretBytes); err != nil { + return "", fmt.Errorf("failed to generate secret: %w", err) + } + secret := fmt.Sprintf("%x", secretBytes) + + clientPublicKey := nostr.GetPublicKey(clientSecretKey) + params := url.Values{} + for _, relay := range relayURLs { + params.Add("relay", relay) + } + params.Set("secret", secret) + + if len(permissions) > 0 { + params.Set("perms", strings.Join(permissions, ",")) + } + if clientName != "" { + params.Set("name", clientName) + } + if clientURL != "" { + params.Set("url", clientURL) + } + if clientImage != "" { + params.Set("image", clientImage) + } + + connectURL := fmt.Sprintf("nostrconnect://%s?%s", clientPublicKey.Hex(), params.Encode()) + return connectURL, nil +} + +// NewBunkerFromNostrConnect waits for a client to connect through our nostrconnect:// URL and returns a BunkerClient on success +func NewBunkerFromNostrConnect( + ctx context.Context, + clientSecretKey [32]byte, + relayURLs []string, + secret string, + pool *nostr.Pool, +) (*BunkerClient, error) { + if pool == nil { + pool = nostr.NewPool(nostr.PoolOptions{}) + } + + if len(relayURLs) == 0 { + return nil, fmt.Errorf("at least one relay URL is required") + } + + clientPublicKey := nostr.GetPublicKey(clientSecretKey) + + // subscribe to events addressed to our client public key + for ie := range pool.SubscribeMany(ctx, relayURLs, nostr.Filter{ + Tags: nostr.TagMap{"p": []string{clientPublicKey.Hex()}}, + Kinds: []nostr.Kind{nostr.KindNostrConnect}, + Since: nostr.Now(), + LimitZero: true, + }, nostr.SubscriptionOptions{Label: "nostrconnect-client"}) { + if ie.Kind != nostr.KindNostrConnect { + continue + } + if ie.PubKey.String() == clientPublicKey.Hex() { + continue + } + + // decrypt and process the connect response + targetPublicKey := ie.PubKey + conversationKey, err := nip44.GenerateConversationKey(targetPublicKey, clientSecretKey) + if err != nil { + continue + } + plain, err := nip44.Decrypt(ie.Content, conversationKey) + if err != nil { + continue + } + + var req Response // contradictorily the thing starts with a response + if err := json.Unmarshal([]byte(plain), &req); err != nil { + continue + } + + if req.Result != "" { + if req.Result == secret { + // secret validation passed - connection established + return &BunkerClient{ + pool: pool, + clientSecretKey: clientSecretKey, + target: targetPublicKey, + relays: relayURLs, + conversationKey: conversationKey, + listeners: xsync.NewMapOf[string, chan Response](), + onAuth: func(string) {}, + idPrefix: "nl-" + strconv.Itoa(mrand.Intn(65536)), + }, nil + } + } + } + + return nil, NoConnectionReceived +}