uri.go raw

   1  package nrc
   2  
   3  import (
   4  	"errors"
   5  	"net/url"
   6  
   7  	"next.orly.dev/pkg/nostr/crypto/encryption"
   8  	"next.orly.dev/pkg/nostr/encoders/hex"
   9  	"next.orly.dev/pkg/nostr/interfaces/signer"
  10  	"next.orly.dev/pkg/nostr/interfaces/signer/p8k"
  11  	"next.orly.dev/pkg/lol/chk"
  12  )
  13  
  14  // AuthMode defines the authentication mode for NRC connections.
  15  type AuthMode int
  16  
  17  const (
  18  	// AuthModeSecret uses a shared secret for authentication.
  19  	AuthModeSecret AuthMode = iota
  20  )
  21  
  22  // ConnectionURI represents a parsed nostr+relayconnect:// URI.
  23  type ConnectionURI struct {
  24  	// RelayPubkey is the public key of the private relay (32 bytes).
  25  	RelayPubkey []byte
  26  	// RendezvousRelay is the WebSocket URL of the public relay.
  27  	RendezvousRelay string
  28  	// AuthMode indicates the authentication mode.
  29  	AuthMode AuthMode
  30  	// DeviceName is an optional human-readable device identifier.
  31  	DeviceName string
  32  
  33  	// Secret-based authentication fields
  34  	clientSecretKey signer.I
  35  	conversationKey []byte
  36  }
  37  
  38  // GetClientSigner returns the signer derived from the secret (secret-based auth only).
  39  func (c *ConnectionURI) GetClientSigner() signer.I {
  40  	return c.clientSecretKey
  41  }
  42  
  43  // GetConversationKey returns the NIP-44 conversation key (secret-based auth only).
  44  func (c *ConnectionURI) GetConversationKey() []byte {
  45  	return c.conversationKey
  46  }
  47  
  48  // ParseConnectionURI parses a nostr+relayconnect:// URI.
  49  //
  50  // URI format:
  51  //
  52  //	nostr+relayconnect://<relay-pubkey>?relay=<rendezvous-relay>&secret=<client-secret>[&name=<device-name>]
  53  func ParseConnectionURI(nrcURI string) (conn *ConnectionURI, err error) {
  54  	var p *url.URL
  55  	if p, err = url.Parse(nrcURI); chk.E(err) {
  56  		return
  57  	}
  58  	if p == nil {
  59  		err = errors.New("invalid uri")
  60  		return
  61  	}
  62  
  63  	conn = &ConnectionURI{}
  64  
  65  	// Validate scheme
  66  	if p.Scheme != "nostr+relayconnect" {
  67  		err = errors.New("incorrect scheme: expected nostr+relayconnect")
  68  		return
  69  	}
  70  
  71  	// Parse relay pubkey from host
  72  	if conn.RelayPubkey, err = hex.Dec(p.Host); chk.E(err) {
  73  		err = errors.New("invalid relay public key")
  74  		return
  75  	}
  76  	if len(conn.RelayPubkey) != 32 {
  77  		err = errors.New("relay public key must be 32 bytes")
  78  		return
  79  	}
  80  
  81  	query := p.Query()
  82  
  83  	// Parse rendezvous relay URL (required)
  84  	relayParam := query.Get("relay")
  85  	if relayParam == "" {
  86  		err = errors.New("missing relay parameter")
  87  		return
  88  	}
  89  	conn.RendezvousRelay = relayParam
  90  
  91  	// Parse optional device name
  92  	conn.DeviceName = query.Get("name")
  93  
  94  	conn.AuthMode = AuthModeSecret
  95  	// Parse secret for secret-based auth
  96  	secret := query.Get("secret")
  97  	if secret == "" {
  98  		err = errors.New("missing secret parameter")
  99  		return
 100  	}
 101  
 102  	var secretBytes []byte
 103  	if secretBytes, err = hex.Dec(secret); chk.E(err) {
 104  		err = errors.New("invalid secret: must be hex-encoded")
 105  		return
 106  	}
 107  	if len(secretBytes) != 32 {
 108  		err = errors.New("secret must be 32 bytes")
 109  		return
 110  	}
 111  
 112  	// Create signer from secret
 113  	var clientKey *p8k.Signer
 114  	if clientKey, err = p8k.New(); chk.E(err) {
 115  		return
 116  	}
 117  	if err = clientKey.InitSec(secretBytes); chk.E(err) {
 118  		return
 119  	}
 120  	conn.clientSecretKey = clientKey
 121  
 122  	// Generate conversation key using NIP-44 key derivation
 123  	if conn.conversationKey, err = encryption.GenerateConversationKey(
 124  		clientKey.Sec(),
 125  		conn.RelayPubkey,
 126  	); chk.E(err) {
 127  		return
 128  	}
 129  
 130  	return
 131  }
 132  
 133  // GenerateConnectionURI creates a new NRC connection URI with a random secret.
 134  func GenerateConnectionURI(relayPubkey []byte, rendezvousRelay string, deviceName string) (uri string, secret []byte, err error) {
 135  	if len(relayPubkey) != 32 {
 136  		err = errors.New("relay public key must be 32 bytes")
 137  		return
 138  	}
 139  
 140  	// Generate random 32-byte secret
 141  	var clientKey *p8k.Signer
 142  	if clientKey, err = p8k.New(); chk.E(err) {
 143  		return
 144  	}
 145  	if err = clientKey.Generate(); chk.E(err) {
 146  		return
 147  	}
 148  	secret = clientKey.Sec()
 149  
 150  	// Build URI
 151  	u := &url.URL{
 152  		Scheme: "nostr+relayconnect",
 153  		Host:   string(hex.Enc(relayPubkey)),
 154  	}
 155  	q := u.Query()
 156  	q.Set("relay", rendezvousRelay)
 157  	q.Set("secret", string(hex.Enc(secret)))
 158  	if deviceName != "" {
 159  		q.Set("name", deviceName)
 160  	}
 161  	u.RawQuery = q.Encode()
 162  	uri = u.String()
 163  	return
 164  }
 165  
 166