nip46: nostrconnect:// preliminary support (client-side).

This commit is contained in:
fiatjaf
2026-01-20 17:49:01 -03:00
parent ce7d165c9d
commit db61f42b56

130
nip46/nostrconnect.go Normal file
View File

@@ -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
}