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 }