Add NRC (Nostr Relay Connect) protocol and web UI (v0.48.9)
Some checks failed
Go / build-and-release (push) Has been cancelled

- Implement NIP-NRC protocol for remote relay access through public relay tunnel
- Add NRC bridge service with NIP-44 encrypted message tunneling
- Add NRC client library for applications
- Add session management with subscription tracking and expiry
- Add URI parsing for nostr+relayconnect:// scheme with secret and CAT auth
- Add NRC API endpoints for connection management (create/list/delete/get-uri)
- Add RelayConnectView.svelte component for managing NRC connections in web UI
- Add NRC database storage for connection secrets and labels
- Add NRC CLI commands (generate, list, revoke)
- Add support for Cashu Access Tokens (CAT) in NRC URIs
- Add ScopeNRC constant for Cashu token scope
- Add wasm build infrastructure and stub files

Files modified:
- app/config/config.go: NRC configuration options
- app/handle-nrc.go: New API handlers for NRC connections
- app/main.go: NRC bridge startup integration
- app/server.go: Register NRC API routes
- app/web/src/App.svelte: Add Relay Connect tab
- app/web/src/RelayConnectView.svelte: New NRC management component
- app/web/src/api.js: NRC API client functions
- main.go: NRC CLI command handlers
- pkg/bunker/acl_adapter.go: Add NRC scope mapping
- pkg/cashu/token/token.go: Add ScopeNRC constant
- pkg/database/nrc.go: NRC connection storage
- pkg/protocol/nrc/: New NRC protocol implementation
- docs/NIP-NRC.md: NIP specification document

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
woikos
2026-01-07 03:40:12 +01:00
parent 0dac41e35e
commit d41c332d06
31 changed files with 5982 additions and 16 deletions

623
pkg/protocol/nrc/bridge.go Normal file
View File

@@ -0,0 +1,623 @@
package nrc
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"git.mleku.dev/mleku/nostr/crypto/encryption"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/encoders/timestamp"
"git.mleku.dev/mleku/nostr/interfaces/signer"
"git.mleku.dev/mleku/nostr/ws"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/cashu/token"
"next.orly.dev/pkg/cashu/verifier"
)
const (
// KindNRCRequest is the event kind for NRC requests.
KindNRCRequest = 24891
// KindNRCResponse is the event kind for NRC responses.
KindNRCResponse = 24892
)
// BridgeConfig holds configuration for the NRC bridge.
type BridgeConfig struct {
// RendezvousURL is the WebSocket URL of the public relay.
RendezvousURL string
// LocalRelayURL is the WebSocket URL of the local private relay.
LocalRelayURL string
// Signer is the relay's signer for signing response events.
Signer signer.I
// AuthorizedSecrets maps derived pubkeys to device names (secret-based auth).
AuthorizedSecrets map[string]string
// CashuVerifier is used for CAT token verification (optional).
CashuVerifier *verifier.Verifier
// SessionTimeout is the inactivity timeout for sessions.
SessionTimeout time.Duration
}
// Bridge connects a private relay to a public rendezvous relay.
type Bridge struct {
config *BridgeConfig
sessions *SessionManager
// rendezvousConn is the connection to the rendezvous relay.
rendezvousConn *ws.Client
// mu protects connection state.
mu sync.RWMutex
// ctx is the bridge context.
ctx context.Context
// cancel cancels the bridge context.
cancel context.CancelFunc
}
// NewBridge creates a new NRC bridge.
func NewBridge(config *BridgeConfig) *Bridge {
ctx, cancel := context.WithCancel(context.Background())
timeout := config.SessionTimeout
if timeout == 0 {
timeout = DefaultSessionTimeout
}
return &Bridge{
config: config,
sessions: NewSessionManager(timeout),
ctx: ctx,
cancel: cancel,
}
}
// Start starts the bridge and begins listening for NRC requests.
func (b *Bridge) Start() error {
log.I.F("starting NRC bridge, rendezvous: %s, local: %s",
b.config.RendezvousURL, b.config.LocalRelayURL)
// Start session cleanup goroutine
go b.cleanupLoop()
// Start the main bridge loop with auto-reconnection
go b.runLoop()
return nil
}
// Stop stops the bridge.
func (b *Bridge) Stop() {
log.I.F("stopping NRC bridge")
b.cancel()
b.sessions.Close()
b.mu.Lock()
defer b.mu.Unlock()
if b.rendezvousConn != nil {
b.rendezvousConn.Close()
}
}
// UpdateAuthorizedSecrets updates the map of authorized secrets.
// This allows dynamic management of authorized connections through the UI.
func (b *Bridge) UpdateAuthorizedSecrets(secrets map[string]string) {
b.mu.Lock()
defer b.mu.Unlock()
b.config.AuthorizedSecrets = secrets
}
// cleanupLoop periodically cleans up expired sessions.
func (b *Bridge) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-b.ctx.Done():
return
case <-ticker.C:
removed := b.sessions.CleanupExpired()
if removed > 0 {
log.D.F("cleaned up %d expired NRC sessions", removed)
}
}
}
}
// runLoop runs the main bridge loop with auto-reconnection.
func (b *Bridge) runLoop() {
delay := time.Second
for {
select {
case <-b.ctx.Done():
return
default:
}
err := b.runOnce()
if err != nil {
if b.ctx.Err() != nil {
return // Context cancelled, exit cleanly
}
log.W.F("NRC bridge error: %v, reconnecting in %v", err, delay)
select {
case <-time.After(delay):
if delay < 30*time.Second {
delay *= 2
}
case <-b.ctx.Done():
return
}
continue
}
delay = time.Second
}
}
// runOnce runs a single iteration of the bridge.
func (b *Bridge) runOnce() error {
// Connect to rendezvous relay
rendezvousConn, err := ws.RelayConnect(b.ctx, b.config.RendezvousURL)
if chk.E(err) {
return fmt.Errorf("%w: %v", ErrRendezvousConnectionFailed, err)
}
defer rendezvousConn.Close()
b.mu.Lock()
b.rendezvousConn = rendezvousConn
b.mu.Unlock()
// Subscribe to NRC request events
relayPubkeyHex := hex.Enc(b.config.Signer.Pub())
sub, err := rendezvousConn.Subscribe(
b.ctx,
filter.NewS(&filter.F{
Kinds: kind.NewS(kind.New(KindNRCRequest)),
Tags: tag.NewS(
tag.NewFromAny("p", relayPubkeyHex),
),
Since: &timestamp.T{V: time.Now().Unix()},
}),
)
if chk.E(err) {
return fmt.Errorf("subscription failed: %w", err)
}
defer sub.Unsub()
log.I.F("NRC bridge listening for requests on %s", b.config.RendezvousURL)
// Process incoming request events
for {
select {
case <-b.ctx.Done():
return nil
case ev := <-sub.Events:
if ev == nil {
return fmt.Errorf("subscription closed")
}
go b.handleRequest(ev)
}
}
}
// handleRequest handles a single NRC request event.
func (b *Bridge) handleRequest(ev *event.E) {
ctx, cancel := context.WithTimeout(b.ctx, 30*time.Second)
defer cancel()
// Extract session ID from tags
sessionID := ""
sessionTag := ev.Tags.GetFirst([]byte("session"))
if sessionTag != nil && sessionTag.Len() >= 2 {
sessionID = string(sessionTag.Value())
}
if sessionID == "" {
log.W.F("NRC request missing session tag from %s", hex.Enc(ev.Pubkey[:]))
return
}
// Verify authorization
conversationKey, authMode, deviceName, err := b.authorize(ctx, ev)
if err != nil {
log.W.F("NRC authorization failed for %s: %v", hex.Enc(ev.Pubkey[:]), err)
b.sendError(ctx, ev, sessionID, "unauthorized: "+err.Error())
return
}
// Get or create session
session := b.sessions.GetOrCreate(sessionID, ev.Pubkey[:], conversationKey, authMode, deviceName)
session.Touch()
// Decrypt request content
decrypted, err := encryption.Decrypt(conversationKey, string(ev.Content))
if err != nil {
log.W.F("NRC decryption failed: %v", err)
b.sendError(ctx, ev, sessionID, "decryption failed")
return
}
// Parse request message
reqMsg, err := ParseRequestContent([]byte(decrypted))
if err != nil {
log.W.F("NRC invalid request format: %v", err)
b.sendError(ctx, ev, sessionID, "invalid request format")
return
}
log.D.F("NRC request: type=%s session=%s from=%s",
reqMsg.Type, sessionID, hex.Enc(ev.Pubkey[:]))
// Forward to local relay and handle response
if err := b.forwardToLocalRelay(ctx, session, ev, reqMsg); err != nil {
log.W.F("NRC forward failed: %v", err)
b.sendError(ctx, ev, sessionID, "relay error: "+err.Error())
}
}
// authorize checks if the request is authorized and returns the conversation key.
func (b *Bridge) authorize(ctx context.Context, ev *event.E) (conversationKey []byte, authMode AuthMode, deviceName string, err error) {
clientPubkey := ev.Pubkey[:]
clientPubkeyHex := string(hex.Enc(clientPubkey))
// Check for CAT token in tags
cashuTag := ev.Tags.GetFirst([]byte("cashu"))
if cashuTag != nil && cashuTag.Len() >= 2 {
// CAT authentication
if b.config.CashuVerifier == nil {
err = fmt.Errorf("CAT auth not configured")
return
}
tokenStr := string(cashuTag.Value())
var tok *token.Token
tok, err = token.Parse(tokenStr)
if chk.E(err) {
err = fmt.Errorf("invalid CAT token: %w", err)
return
}
if err = b.config.CashuVerifier.VerifyForScope(ctx, tok, token.ScopeNRC, ""); chk.E(err) {
return
}
// CAT auth uses ECDH between relay key and client's Nostr key
conversationKey, err = encryption.GenerateConversationKey(
b.config.Signer.Sec(),
clientPubkey,
)
if chk.E(err) {
return
}
authMode = AuthModeCAT
return
}
// Secret-based authentication: check if client pubkey is in authorized list
if name, ok := b.config.AuthorizedSecrets[clientPubkeyHex]; ok {
// Secret auth uses ECDH between relay key and client's derived key
conversationKey, err = encryption.GenerateConversationKey(
b.config.Signer.Sec(),
clientPubkey,
)
if chk.E(err) {
return
}
authMode = AuthModeSecret
deviceName = name
return
}
err = ErrUnauthorized
return
}
// forwardToLocalRelay forwards a request to the local relay and handles responses.
func (b *Bridge) forwardToLocalRelay(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage) error {
// Connect to local relay
localConn, err := ws.RelayConnect(ctx, b.config.LocalRelayURL)
if chk.E(err) {
return fmt.Errorf("%w: %v", ErrRelayConnectionFailed, err)
}
defer localConn.Close()
// Handle different message types
switch reqMsg.Type {
case "REQ":
return b.handleREQ(ctx, session, reqEvent, reqMsg, localConn)
case "EVENT":
return b.handleEVENT(ctx, session, reqEvent, reqMsg, localConn)
case "CLOSE":
return b.handleCLOSE(ctx, session, reqEvent, reqMsg)
case "COUNT":
return b.handleCOUNT(ctx, session, reqEvent, reqMsg, localConn)
default:
return fmt.Errorf("unsupported message type: %s", reqMsg.Type)
}
}
// handleREQ handles a REQ message and forwards responses.
func (b *Bridge) handleREQ(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
// Extract subscription ID and filters from payload
// Payload: ["REQ", "<sub_id>", filter1, filter2, ...]
if len(reqMsg.Payload) < 3 {
return fmt.Errorf("invalid REQ payload")
}
subID, ok := reqMsg.Payload[1].(string)
if !ok {
return fmt.Errorf("invalid subscription ID")
}
// Parse filters from payload
var filters []*filter.F
for i := 2; i < len(reqMsg.Payload); i++ {
filterMap, ok := reqMsg.Payload[i].(map[string]any)
if !ok {
continue
}
filterBytes, err := json.Marshal(filterMap)
if err != nil {
continue
}
var f filter.F
if err := json.Unmarshal(filterBytes, &f); err != nil {
continue
}
filters = append(filters, &f)
}
if len(filters) == 0 {
return fmt.Errorf("no valid filters in REQ")
}
// Add subscription to session
if err := session.AddSubscription(subID); err != nil {
return err
}
// Create filter set
filterSet := filter.NewS(filters...)
// Subscribe to local relay
sub, err := conn.Subscribe(ctx, filterSet)
if chk.E(err) {
session.RemoveSubscription(subID)
return fmt.Errorf("local subscribe failed: %w", err)
}
defer sub.Unsub()
// Forward events until EOSE or timeout
for {
select {
case <-ctx.Done():
return ctx.Err()
case ev := <-sub.Events:
if ev == nil {
// Subscription closed, send EOSE
resp := &ResponseMessage{
Type: "EOSE",
Payload: []any{"EOSE", subID},
}
return b.sendResponse(ctx, reqEvent, session, resp)
}
// Convert event to JSON-compatible map
eventBytes, err := json.Marshal(ev)
if err != nil {
continue
}
var eventMap map[string]any
if err := json.Unmarshal(eventBytes, &eventMap); err != nil {
continue
}
// Send EVENT response
resp := &ResponseMessage{
Type: "EVENT",
Payload: []any{"EVENT", subID, eventMap},
}
if err := b.sendResponse(ctx, reqEvent, session, resp); err != nil {
log.W.F("failed to send event response: %v", err)
}
session.IncrementEventCount(subID)
case <-sub.EndOfStoredEvents:
// Send EOSE
session.MarkEOSE(subID)
resp := &ResponseMessage{
Type: "EOSE",
Payload: []any{"EOSE", subID},
}
return b.sendResponse(ctx, reqEvent, session, resp)
}
}
}
// handleEVENT handles an EVENT message and forwards the OK response.
func (b *Bridge) handleEVENT(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
// Extract event from payload: ["EVENT", {...event...}]
if len(reqMsg.Payload) < 2 {
return fmt.Errorf("invalid EVENT payload")
}
eventMap, ok := reqMsg.Payload[1].(map[string]any)
if !ok {
return fmt.Errorf("invalid event data")
}
// Parse event
eventBytes, err := json.Marshal(eventMap)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
var ev event.E
if err := json.Unmarshal(eventBytes, &ev); err != nil {
return fmt.Errorf("failed to unmarshal event: %w", err)
}
// Publish to local relay
err = conn.Publish(ctx, &ev)
success := err == nil
message := ""
if err != nil {
message = err.Error()
}
// Send OK response
resp := &ResponseMessage{
Type: "OK",
Payload: []any{"OK", string(hex.Enc(ev.ID[:])), success, message},
}
return b.sendResponse(ctx, reqEvent, session, resp)
}
// handleCLOSE handles a CLOSE message.
func (b *Bridge) handleCLOSE(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage) error {
// Extract subscription ID: ["CLOSE", "<sub_id>"]
if len(reqMsg.Payload) >= 2 {
if subID, ok := reqMsg.Payload[1].(string); ok {
session.RemoveSubscription(subID)
}
}
// CLOSE doesn't have a response
return nil
}
// handleCOUNT handles a COUNT message.
func (b *Bridge) handleCOUNT(ctx context.Context, session *Session, reqEvent *event.E, reqMsg *RequestMessage, conn *ws.Client) error {
// COUNT is not supported via ws.Client directly, return error
resp := &ResponseMessage{
Type: "NOTICE",
Payload: []any{"NOTICE", "COUNT not supported through NRC tunnel"},
}
return b.sendResponse(ctx, reqEvent, session, resp)
}
// sendResponse encrypts and sends a response to the client.
func (b *Bridge) sendResponse(ctx context.Context, reqEvent *event.E, session *Session, resp *ResponseMessage) error {
// Marshal response content
content, err := MarshalResponseContent(resp)
if err != nil {
return fmt.Errorf("marshal failed: %w", err)
}
// Encrypt content
encrypted, err := encryption.Encrypt(session.ConversationKey, content, nil)
if err != nil {
return fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
}
// Build response event
respEvent := &event.E{
Content: []byte(encrypted),
CreatedAt: time.Now().Unix(),
Kind: KindNRCResponse,
Tags: tag.NewS(
tag.NewFromAny("p", hex.Enc(reqEvent.Pubkey[:])),
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("session", session.ID),
tag.NewFromAny("e", hex.Enc(reqEvent.ID[:])),
),
}
// Sign with relay key
if err := respEvent.Sign(b.config.Signer); chk.E(err) {
return fmt.Errorf("signing failed: %w", err)
}
// Publish to rendezvous relay
b.mu.RLock()
conn := b.rendezvousConn
b.mu.RUnlock()
if conn == nil {
return fmt.Errorf("not connected to rendezvous relay")
}
if err := conn.Publish(ctx, respEvent); chk.E(err) {
return fmt.Errorf("publish failed: %w", err)
}
return nil
}
// sendError sends an error response to the client.
func (b *Bridge) sendError(ctx context.Context, reqEvent *event.E, sessionID string, errMsg string) {
// For errors, we need to get or create a conversation key
// This is best-effort since we may not be able to authenticate
conversationKey, err := encryption.GenerateConversationKey(
b.config.Signer.Sec(),
reqEvent.Pubkey[:],
)
if err != nil {
log.W.F("failed to generate conversation key for error response: %v", err)
return
}
resp := &ResponseMessage{
Type: "NOTICE",
Payload: []any{"NOTICE", "nrc: " + errMsg},
}
content, err := MarshalResponseContent(resp)
if err != nil {
return
}
encrypted, err := encryption.Encrypt(conversationKey, content, nil)
if err != nil {
return
}
respEvent := &event.E{
Content: []byte(encrypted),
CreatedAt: time.Now().Unix(),
Kind: KindNRCResponse,
Tags: tag.NewS(
tag.NewFromAny("p", hex.Enc(reqEvent.Pubkey[:])),
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("session", sessionID),
tag.NewFromAny("e", hex.Enc(reqEvent.ID[:])),
),
}
if err := respEvent.Sign(b.config.Signer); err != nil {
return
}
b.mu.RLock()
conn := b.rendezvousConn
b.mu.RUnlock()
if conn != nil {
conn.Publish(ctx, respEvent)
}
}
// AddAuthorizedSecret adds an authorized secret (derived pubkey).
func (b *Bridge) AddAuthorizedSecret(pubkeyHex, deviceName string) {
b.config.AuthorizedSecrets[pubkeyHex] = deviceName
}
// RemoveAuthorizedSecret removes an authorized secret.
func (b *Bridge) RemoveAuthorizedSecret(pubkeyHex string) {
delete(b.config.AuthorizedSecrets, pubkeyHex)
}
// ListAuthorizedSecrets returns a copy of the authorized secrets map.
func (b *Bridge) ListAuthorizedSecrets() map[string]string {
result := make(map[string]string)
for k, v := range b.config.AuthorizedSecrets {
result[k] = v
}
return result
}
// SessionCount returns the number of active sessions.
func (b *Bridge) SessionCount() int {
return b.sessions.Count()
}

513
pkg/protocol/nrc/client.go Normal file
View File

@@ -0,0 +1,513 @@
package nrc
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"git.mleku.dev/mleku/nostr/crypto/encryption"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/encoders/timestamp"
"git.mleku.dev/mleku/nostr/interfaces/signer"
"git.mleku.dev/mleku/nostr/ws"
"github.com/google/uuid"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// Client connects to a private relay through the NRC tunnel.
type Client struct {
uri *ConnectionURI
sessionID string
rendezvousConn *ws.Client
responseSub *ws.Subscription
conversationKey []byte
clientSigner signer.I
// pending maps request event IDs to response channels.
pending map[string]chan *ResponseMessage
pendingMu sync.Mutex
// subscriptions maps subscription IDs to event channels.
subscriptions map[string]chan *event.E
subscriptionsMu sync.Mutex
ctx context.Context
cancel context.CancelFunc
}
// NewClient creates a new NRC client from a connection URI.
func NewClient(connectionURI string) (*Client, error) {
uri, err := ParseConnectionURI(connectionURI)
if err != nil {
return nil, fmt.Errorf("invalid URI: %w", err)
}
if uri.AuthMode != AuthModeSecret {
return nil, fmt.Errorf("CAT authentication not yet supported in client")
}
ctx, cancel := context.WithCancel(context.Background())
return &Client{
uri: uri,
sessionID: uuid.New().String(),
conversationKey: uri.GetConversationKey(),
clientSigner: uri.GetClientSigner(),
pending: make(map[string]chan *ResponseMessage),
subscriptions: make(map[string]chan *event.E),
ctx: ctx,
cancel: cancel,
}, nil
}
// Connect establishes the connection to the rendezvous relay.
func (c *Client) Connect(ctx context.Context) error {
// Connect to rendezvous relay
conn, err := ws.RelayConnect(ctx, c.uri.RendezvousRelay)
if chk.E(err) {
return fmt.Errorf("%w: %v", ErrRendezvousConnectionFailed, err)
}
c.rendezvousConn = conn
// Subscribe to response events
clientPubkeyHex := hex.Enc(c.clientSigner.Pub())
sub, err := conn.Subscribe(
ctx,
filter.NewS(&filter.F{
Kinds: kind.NewS(kind.New(KindNRCResponse)),
Tags: tag.NewS(
tag.NewFromAny("p", clientPubkeyHex),
),
Since: &timestamp.T{V: time.Now().Unix()},
}),
)
if chk.E(err) {
conn.Close()
return fmt.Errorf("subscription failed: %w", err)
}
c.responseSub = sub
// Start response handler
go c.handleResponses()
log.I.F("NRC client connected to %s via %s",
hex.Enc(c.uri.RelayPubkey), c.uri.RendezvousRelay)
return nil
}
// Close closes the client connection.
func (c *Client) Close() {
c.cancel()
if c.responseSub != nil {
c.responseSub.Unsub()
}
if c.rendezvousConn != nil {
c.rendezvousConn.Close()
}
// Close all pending channels
c.pendingMu.Lock()
for _, ch := range c.pending {
close(ch)
}
c.pending = make(map[string]chan *ResponseMessage)
c.pendingMu.Unlock()
// Close all subscription channels
c.subscriptionsMu.Lock()
for _, ch := range c.subscriptions {
close(ch)
}
c.subscriptions = make(map[string]chan *event.E)
c.subscriptionsMu.Unlock()
}
// handleResponses processes incoming NRC response events.
func (c *Client) handleResponses() {
for {
select {
case <-c.ctx.Done():
return
case ev := <-c.responseSub.Events:
if ev == nil {
return
}
c.processResponse(ev)
}
}
}
// processResponse decrypts and routes a response event.
func (c *Client) processResponse(ev *event.E) {
// Decrypt content
decrypted, err := encryption.Decrypt(c.conversationKey, string(ev.Content))
if err != nil {
log.W.F("NRC response decryption failed: %v", err)
return
}
// Parse response
var resp struct {
Type string `json:"type"`
Payload []any `json:"payload"`
}
if err := json.Unmarshal([]byte(decrypted), &resp); err != nil {
log.W.F("NRC response parse failed: %v", err)
return
}
// Extract request event ID for routing
var requestEventID string
eTag := ev.Tags.GetFirst([]byte("e"))
if eTag != nil && eTag.Len() >= 2 {
requestEventID = string(eTag.ValueHex())
}
// Route based on response type
switch resp.Type {
case "EVENT":
c.handleEventResponse(resp.Payload)
case "EOSE":
c.handleEOSEResponse(resp.Payload, requestEventID)
case "OK":
c.handleOKResponse(resp.Payload, requestEventID)
case "NOTICE":
c.handleNoticeResponse(resp.Payload)
case "CLOSED":
c.handleClosedResponse(resp.Payload)
case "COUNT":
c.handleCountResponse(resp.Payload, requestEventID)
case "AUTH":
c.handleAuthResponse(resp.Payload, requestEventID)
}
}
// handleEventResponse routes an EVENT to the appropriate subscription.
func (c *Client) handleEventResponse(payload []any) {
if len(payload) < 3 {
return
}
// Payload: ["EVENT", "<sub_id>", {...event...}]
subID, ok := payload[1].(string)
if !ok {
return
}
c.subscriptionsMu.Lock()
ch, exists := c.subscriptions[subID]
c.subscriptionsMu.Unlock()
if !exists {
return
}
// Parse event from payload
eventData, ok := payload[2].(map[string]any)
if !ok {
return
}
eventBytes, err := json.Marshal(eventData)
if err != nil {
return
}
var ev event.E
if err := json.Unmarshal(eventBytes, &ev); err != nil {
return
}
select {
case ch <- &ev:
default:
// Channel full, drop event
}
}
// handleEOSEResponse handles an EOSE response.
func (c *Client) handleEOSEResponse(payload []any, requestEventID string) {
// Route to pending request
c.pendingMu.Lock()
ch, exists := c.pending[requestEventID]
c.pendingMu.Unlock()
if exists {
resp := &ResponseMessage{Type: "EOSE", Payload: payload}
select {
case ch <- resp:
default:
}
}
}
// handleOKResponse handles an OK response.
func (c *Client) handleOKResponse(payload []any, requestEventID string) {
c.pendingMu.Lock()
ch, exists := c.pending[requestEventID]
c.pendingMu.Unlock()
if exists {
resp := &ResponseMessage{Type: "OK", Payload: payload}
select {
case ch <- resp:
default:
}
}
}
// handleNoticeResponse logs a NOTICE.
func (c *Client) handleNoticeResponse(payload []any) {
if len(payload) >= 2 {
if msg, ok := payload[1].(string); ok {
log.W.F("NRC NOTICE: %s", msg)
}
}
}
// handleClosedResponse handles a subscription close.
func (c *Client) handleClosedResponse(payload []any) {
if len(payload) >= 2 {
if subID, ok := payload[1].(string); ok {
c.subscriptionsMu.Lock()
if ch, exists := c.subscriptions[subID]; exists {
close(ch)
delete(c.subscriptions, subID)
}
c.subscriptionsMu.Unlock()
}
}
}
// handleCountResponse handles a COUNT response.
func (c *Client) handleCountResponse(payload []any, requestEventID string) {
c.pendingMu.Lock()
ch, exists := c.pending[requestEventID]
c.pendingMu.Unlock()
if exists {
resp := &ResponseMessage{Type: "COUNT", Payload: payload}
select {
case ch <- resp:
default:
}
}
}
// handleAuthResponse handles an AUTH challenge.
func (c *Client) handleAuthResponse(payload []any, requestEventID string) {
c.pendingMu.Lock()
ch, exists := c.pending[requestEventID]
c.pendingMu.Unlock()
if exists {
resp := &ResponseMessage{Type: "AUTH", Payload: payload}
select {
case ch <- resp:
default:
}
}
}
// sendRequest sends an NRC request and waits for response.
func (c *Client) sendRequest(ctx context.Context, msgType string, payload []any) (*ResponseMessage, error) {
// Build request content
reqContent := struct {
Type string `json:"type"`
Payload []any `json:"payload"`
}{
Type: msgType,
Payload: payload,
}
contentBytes, err := json.Marshal(reqContent)
if err != nil {
return nil, fmt.Errorf("marshal failed: %w", err)
}
// Encrypt content
encrypted, err := encryption.Encrypt(c.conversationKey, contentBytes, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
}
// Build request event
reqEvent := &event.E{
Content: []byte(encrypted),
CreatedAt: time.Now().Unix(),
Kind: KindNRCRequest,
Tags: tag.NewS(
tag.NewFromAny("p", hex.Enc(c.uri.RelayPubkey)),
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("session", c.sessionID),
),
}
// Sign with client key
if err := reqEvent.Sign(c.clientSigner); chk.E(err) {
return nil, fmt.Errorf("signing failed: %w", err)
}
// Set up response channel
responseCh := make(chan *ResponseMessage, 1)
requestEventID := string(hex.Enc(reqEvent.ID[:]))
c.pendingMu.Lock()
c.pending[requestEventID] = responseCh
c.pendingMu.Unlock()
defer func() {
c.pendingMu.Lock()
delete(c.pending, requestEventID)
c.pendingMu.Unlock()
}()
// Publish request
if err := c.rendezvousConn.Publish(ctx, reqEvent); chk.E(err) {
return nil, fmt.Errorf("publish failed: %w", err)
}
// Wait for response
select {
case <-ctx.Done():
return nil, ctx.Err()
case resp := <-responseCh:
if resp == nil {
return nil, fmt.Errorf("response channel closed")
}
return resp, nil
}
}
// Publish publishes an event to the private relay.
func (c *Client) Publish(ctx context.Context, ev *event.E) (bool, string, error) {
// Convert event to JSON for payload
eventBytes, err := json.Marshal(ev)
if err != nil {
return false, "", fmt.Errorf("marshal event failed: %w", err)
}
var eventMap map[string]any
if err := json.Unmarshal(eventBytes, &eventMap); err != nil {
return false, "", fmt.Errorf("unmarshal event failed: %w", err)
}
payload := []any{"EVENT", eventMap}
resp, err := c.sendRequest(ctx, "EVENT", payload)
if err != nil {
return false, "", err
}
// Parse OK response: ["OK", "<event_id>", <success>, "<message>"]
if resp.Type != "OK" || len(resp.Payload) < 4 {
return false, "", fmt.Errorf("unexpected response type: %s", resp.Type)
}
success, _ := resp.Payload[2].(bool)
message, _ := resp.Payload[3].(string)
return success, message, nil
}
// Subscribe creates a subscription to the private relay.
func (c *Client) Subscribe(ctx context.Context, subID string, filters ...*filter.F) (<-chan *event.E, error) {
// Build payload: ["REQ", "<sub_id>", filter1, filter2, ...]
payload := []any{"REQ", subID}
for _, f := range filters {
filterBytes, err := json.Marshal(f)
if err != nil {
return nil, fmt.Errorf("marshal filter failed: %w", err)
}
var filterMap map[string]any
if err := json.Unmarshal(filterBytes, &filterMap); err != nil {
return nil, fmt.Errorf("unmarshal filter failed: %w", err)
}
payload = append(payload, filterMap)
}
// Create event channel for this subscription
eventCh := make(chan *event.E, 100)
c.subscriptionsMu.Lock()
c.subscriptions[subID] = eventCh
c.subscriptionsMu.Unlock()
// Send request (don't wait for EOSE, events will come asynchronously)
go func() {
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
_, err := c.sendRequest(reqCtx, "REQ", payload)
if err != nil {
log.W.F("NRC subscribe failed: %v", err)
}
}()
return eventCh, nil
}
// Unsubscribe closes a subscription.
func (c *Client) Unsubscribe(ctx context.Context, subID string) error {
// Remove from local tracking
c.subscriptionsMu.Lock()
if ch, exists := c.subscriptions[subID]; exists {
close(ch)
delete(c.subscriptions, subID)
}
c.subscriptionsMu.Unlock()
// Send CLOSE to relay
payload := []any{"CLOSE", subID}
_, err := c.sendRequest(ctx, "CLOSE", payload)
return err
}
// Count sends a COUNT request to the private relay.
func (c *Client) Count(ctx context.Context, subID string, filters ...*filter.F) (int64, error) {
// Build payload: ["COUNT", "<sub_id>", filter1, filter2, ...]
payload := []any{"COUNT", subID}
for _, f := range filters {
filterBytes, err := json.Marshal(f)
if err != nil {
return 0, fmt.Errorf("marshal filter failed: %w", err)
}
var filterMap map[string]any
if err := json.Unmarshal(filterBytes, &filterMap); err != nil {
return 0, fmt.Errorf("unmarshal filter failed: %w", err)
}
payload = append(payload, filterMap)
}
resp, err := c.sendRequest(ctx, "COUNT", payload)
if err != nil {
return 0, err
}
// Parse COUNT response: ["COUNT", "<sub_id>", {"count": N}]
if resp.Type != "COUNT" || len(resp.Payload) < 3 {
return 0, fmt.Errorf("unexpected response type: %s", resp.Type)
}
countData, ok := resp.Payload[2].(map[string]any)
if !ok {
return 0, fmt.Errorf("invalid count response")
}
count, ok := countData["count"].(float64)
if !ok {
return 0, fmt.Errorf("missing count field")
}
return int64(count), nil
}
// RelayURL returns a pseudo-URL for this NRC connection.
func (c *Client) RelayURL() string {
return "nrc://" + string(hex.Enc(c.uri.RelayPubkey))
}

View File

@@ -0,0 +1,24 @@
package nrc
import "errors"
var (
// ErrUnauthorized is returned when a client is not authorized.
ErrUnauthorized = errors.New("unauthorized")
// ErrInvalidSession is returned when a session ID is invalid or not found.
ErrInvalidSession = errors.New("invalid session")
// ErrTooManySubscriptions is returned when a session has too many subscriptions.
ErrTooManySubscriptions = errors.New("too many subscriptions")
// ErrInvalidMessageType is returned when the message type is invalid.
ErrInvalidMessageType = errors.New("invalid message type")
// ErrSessionExpired is returned when a session has expired.
ErrSessionExpired = errors.New("session expired")
// ErrDecryptionFailed is returned when message decryption fails.
ErrDecryptionFailed = errors.New("decryption failed")
// ErrEncryptionFailed is returned when message encryption fails.
ErrEncryptionFailed = errors.New("encryption failed")
// ErrRelayConnectionFailed is returned when connection to the local relay fails.
ErrRelayConnectionFailed = errors.New("relay connection failed")
// ErrRendezvousConnectionFailed is returned when connection to the rendezvous relay fails.
ErrRendezvousConnectionFailed = errors.New("rendezvous relay connection failed")
)

View File

@@ -0,0 +1,371 @@
package nrc
import (
"testing"
"time"
"git.mleku.dev/mleku/nostr/crypto/keys"
"git.mleku.dev/mleku/nostr/encoders/hex"
)
// Test keys - generated from known secrets for reproducibility
var (
// From secret: 0000000000000000000000000000000000000000000000000000000000000001
testRelaySecret = "0000000000000000000000000000000000000000000000000000000000000001"
// From secret: 0000000000000000000000000000000000000000000000000000000000000002
testClientSecret = "0000000000000000000000000000000000000000000000000000000000000002"
)
// getTestRelayPubkey returns the pubkey derived from testRelaySecret
func getTestRelayPubkey(t *testing.T) []byte {
secretBytes, err := hex.Dec(testRelaySecret)
if err != nil {
t.Fatalf("failed to decode test secret: %v", err)
}
pubkey, err := keys.SecretBytesToPubKeyBytes(secretBytes)
if err != nil {
t.Fatalf("failed to derive pubkey: %v", err)
}
return pubkey
}
// getTestRelayPubkeyHex returns the hex-encoded pubkey
func getTestRelayPubkeyHex(t *testing.T) string {
return string(hex.Enc(getTestRelayPubkey(t)))
}
func TestParseConnectionURI(t *testing.T) {
// Get valid pubkey for tests
relayPubkeyHex := getTestRelayPubkeyHex(t)
tests := []struct {
name string
uri string
wantErr bool
check func(*testing.T, *ConnectionURI)
}{
{
name: "invalid scheme",
uri: "nostr+wallet://abc123",
wantErr: true,
},
{
name: "missing relay parameter",
uri: "nostr+relayconnect://" + relayPubkeyHex,
wantErr: true,
},
{
name: "missing secret parameter",
uri: "nostr+relayconnect://" + relayPubkeyHex + "?relay=wss://relay.example.com",
wantErr: true,
},
{
name: "invalid relay pubkey",
uri: "nostr+relayconnect://invalid?relay=wss://relay.example.com&secret=" + testClientSecret,
wantErr: true,
},
{
name: "valid secret-based URI",
uri: "nostr+relayconnect://" + relayPubkeyHex + "?relay=wss://relay.example.com&secret=" + testClientSecret,
check: func(t *testing.T, conn *ConnectionURI) {
if conn.AuthMode != AuthModeSecret {
t.Errorf("expected AuthModeSecret, got %d", conn.AuthMode)
}
if conn.RendezvousRelay != "wss://relay.example.com" {
t.Errorf("expected wss://relay.example.com, got %s", conn.RendezvousRelay)
}
if conn.GetClientSigner() == nil {
t.Error("expected client signer to be set")
}
if len(conn.GetConversationKey()) != 32 {
t.Errorf("expected 32-byte conversation key, got %d", len(conn.GetConversationKey()))
}
},
},
{
name: "valid URI with device name",
uri: "nostr+relayconnect://" + relayPubkeyHex + "?relay=wss://relay.example.com&secret=" + testClientSecret + "&name=phone",
check: func(t *testing.T, conn *ConnectionURI) {
if conn.DeviceName != "phone" {
t.Errorf("expected device name 'phone', got '%s'", conn.DeviceName)
}
},
},
{
name: "valid CAT-based URI",
uri: "nostr+relayconnect://" + relayPubkeyHex + "?relay=wss://relay.example.com&auth=cat&mint=https://mint.example.com",
check: func(t *testing.T, conn *ConnectionURI) {
if conn.AuthMode != AuthModeCAT {
t.Errorf("expected AuthModeCAT, got %d", conn.AuthMode)
}
if conn.MintURL != "https://mint.example.com" {
t.Errorf("expected mint URL, got %s", conn.MintURL)
}
},
},
{
name: "CAT URI missing mint",
uri: "nostr+relayconnect://" + relayPubkeyHex + "?relay=wss://relay.example.com&auth=cat",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conn, err := ParseConnectionURI(tt.uri)
if (err != nil) != tt.wantErr {
t.Errorf("ParseConnectionURI() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.check != nil && err == nil {
tt.check(t, conn)
}
})
}
}
func TestGenerateConnectionURI(t *testing.T) {
relayPubkey := getTestRelayPubkey(t)
rendezvousRelay := "wss://relay.example.com"
uri, secret, err := GenerateConnectionURI(relayPubkey, rendezvousRelay, "test-device")
if err != nil {
t.Fatalf("GenerateConnectionURI() error = %v", err)
}
if len(secret) != 32 {
t.Errorf("expected 32-byte secret, got %d", len(secret))
}
// Parse the generated URI to verify it's valid
conn, err := ParseConnectionURI(uri)
if err != nil {
t.Fatalf("failed to parse generated URI: %v", err)
}
if conn.DeviceName != "test-device" {
t.Errorf("expected device name 'test-device', got '%s'", conn.DeviceName)
}
if conn.RendezvousRelay != rendezvousRelay {
t.Errorf("expected rendezvous relay %s, got %s", rendezvousRelay, conn.RendezvousRelay)
}
}
func TestSession(t *testing.T) {
clientPubkey := make([]byte, 32)
conversationKey := make([]byte, 32)
session := NewSession("test-session", clientPubkey, conversationKey, AuthModeSecret, "test-device")
if session == nil {
t.Fatal("NewSession returned nil")
}
// Test basic properties
if session.ID != "test-session" {
t.Errorf("expected ID 'test-session', got '%s'", session.ID)
}
if session.DeviceName != "test-device" {
t.Errorf("expected device name 'test-device', got '%s'", session.DeviceName)
}
if session.AuthMode != AuthModeSecret {
t.Errorf("expected AuthModeSecret, got %d", session.AuthMode)
}
// Test subscription management
if err := session.AddSubscription("sub1"); err != nil {
t.Errorf("AddSubscription() error = %v", err)
}
if !session.HasSubscription("sub1") {
t.Error("expected subscription 'sub1' to exist")
}
if session.SubscriptionCount() != 1 {
t.Errorf("expected 1 subscription, got %d", session.SubscriptionCount())
}
session.RemoveSubscription("sub1")
if session.HasSubscription("sub1") {
t.Error("expected subscription 'sub1' to be removed")
}
// Test expiry
if session.IsExpired(time.Hour) {
t.Error("session should not be expired")
}
// Test close
session.Close()
select {
case <-session.Context().Done():
// Expected
default:
t.Error("expected session context to be cancelled after Close()")
}
}
func TestSessionManager(t *testing.T) {
manager := NewSessionManager(time.Minute)
if manager == nil {
t.Fatal("NewSessionManager returned nil")
}
clientPubkey := make([]byte, 32)
conversationKey := make([]byte, 32)
// Test GetOrCreate
session := manager.GetOrCreate("session1", clientPubkey, conversationKey, AuthModeSecret, "device1")
if session == nil {
t.Fatal("GetOrCreate returned nil")
}
// Get same session again
session2 := manager.GetOrCreate("session1", clientPubkey, conversationKey, AuthModeSecret, "device1")
if session2 != session {
t.Error("expected GetOrCreate to return same session")
}
// Test Get
retrieved := manager.Get("session1")
if retrieved != session {
t.Error("expected Get to return the session")
}
// Test Count
if manager.Count() != 1 {
t.Errorf("expected count 1, got %d", manager.Count())
}
// Test Remove
manager.Remove("session1")
if manager.Get("session1") != nil {
t.Error("expected session to be removed")
}
if manager.Count() != 0 {
t.Errorf("expected count 0 after removal, got %d", manager.Count())
}
// Test Close
manager.GetOrCreate("session2", clientPubkey, conversationKey, AuthModeSecret, "device2")
manager.Close()
if manager.Count() != 0 {
t.Errorf("expected count 0 after Close, got %d", manager.Count())
}
}
func TestParseRequestContent(t *testing.T) {
tests := []struct {
name string
content string
wantErr bool
check func(*testing.T, *RequestMessage)
}{
{
name: "empty content",
content: "",
wantErr: true,
},
{
name: "invalid JSON",
content: "not json",
wantErr: true,
},
{
name: "missing type",
content: `{"payload": []}`,
wantErr: true,
},
{
name: "valid EVENT request",
content: `{"type": "EVENT", "payload": ["EVENT", {}]}`,
check: func(t *testing.T, msg *RequestMessage) {
if msg.Type != "EVENT" {
t.Errorf("expected type EVENT, got %s", msg.Type)
}
},
},
{
name: "valid REQ request",
content: `{"type": "REQ", "payload": ["REQ", "sub1", {}]}`,
check: func(t *testing.T, msg *RequestMessage) {
if msg.Type != "REQ" {
t.Errorf("expected type REQ, got %s", msg.Type)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg, err := ParseRequestContent([]byte(tt.content))
if (err != nil) != tt.wantErr {
t.Errorf("ParseRequestContent() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.check != nil && err == nil {
tt.check(t, msg)
}
})
}
}
func TestMarshalResponseContent(t *testing.T) {
resp := &ResponseMessage{
Type: "OK",
Payload: []any{"OK", "eventid123", true, ""},
}
content, err := MarshalResponseContent(resp)
if err != nil {
t.Fatalf("MarshalResponseContent() error = %v", err)
}
// Verify it's valid JSON that can be parsed back
parsed, err := ParseRequestContent(content)
if err != nil {
t.Fatalf("failed to parse marshaled response: %v", err)
}
if parsed.Type != "OK" {
t.Errorf("expected type OK, got %s", parsed.Type)
}
}
func TestBridgeConfig(t *testing.T) {
config := &BridgeConfig{
RendezvousURL: "wss://relay.example.com",
LocalRelayURL: "ws://localhost:3334",
AuthorizedSecrets: map[string]string{"pubkey1": "device1"},
SessionTimeout: time.Minute,
}
bridge := NewBridge(config)
if bridge == nil {
t.Fatal("NewBridge returned nil")
}
// Bridge shouldn't start without a valid rendezvous relay
// but we can verify it was created
bridge.Stop()
}
func TestSubscriptionTooMany(t *testing.T) {
clientPubkey := make([]byte, 32)
conversationKey := make([]byte, 32)
session := NewSession("test-session", clientPubkey, conversationKey, AuthModeSecret, "test-device")
// Add max subscriptions
for i := 0; i < DefaultMaxSubscriptions; i++ {
if err := session.AddSubscription(string(rune(i))); err != nil {
t.Fatalf("AddSubscription() error = %v at iteration %d", err, i)
}
}
// Next one should fail
err := session.AddSubscription("overflow")
if err != ErrTooManySubscriptions {
t.Errorf("expected ErrTooManySubscriptions, got %v", err)
}
session.Close()
}

322
pkg/protocol/nrc/session.go Normal file
View File

@@ -0,0 +1,322 @@
package nrc
import (
"context"
"encoding/json"
"sync"
"time"
)
const (
// DefaultSessionTimeout is the default inactivity timeout for sessions.
DefaultSessionTimeout = 30 * time.Minute
// DefaultMaxSubscriptions is the default maximum subscriptions per session.
DefaultMaxSubscriptions = 100
)
// Session represents an NRC client session through the tunnel.
type Session struct {
// ID is the unique session identifier.
ID string
// ClientPubkey is the public key of the connected client.
ClientPubkey []byte
// ConversationKey is the NIP-44 conversation key for this session.
ConversationKey []byte
// DeviceName is the optional device identifier.
DeviceName string
// AuthMode is the authentication mode used.
AuthMode AuthMode
// CreatedAt is when the session was created.
CreatedAt time.Time
// LastActivity is the timestamp of the last activity.
LastActivity time.Time
// subscriptions maps client subscription IDs to internal subscription state.
subscriptions map[string]*Subscription
// subMu protects the subscriptions map.
subMu sync.RWMutex
// ctx is the session context.
ctx context.Context
// cancel cancels the session context.
cancel context.CancelFunc
// eventCh receives events from the local relay for this session.
eventCh chan *SessionEvent
}
// Subscription represents a tunneled subscription.
type Subscription struct {
// ID is the client's subscription ID.
ID string
// CreatedAt is when the subscription was created.
CreatedAt time.Time
// EventCount tracks how many events have been sent.
EventCount int64
// EOSESent indicates whether EOSE has been sent.
EOSESent bool
}
// SessionEvent wraps a relay response for delivery to the client.
type SessionEvent struct {
// Type is the response type (EVENT, OK, EOSE, NOTICE, CLOSED, COUNT, AUTH).
Type string
// Payload is the response payload array.
Payload []any
// RequestEventID is the ID of the request event this responds to (if applicable).
RequestEventID string
}
// NewSession creates a new session.
func NewSession(id string, clientPubkey, conversationKey []byte, authMode AuthMode, deviceName string) *Session {
ctx, cancel := context.WithCancel(context.Background())
now := time.Now()
return &Session{
ID: id,
ClientPubkey: clientPubkey,
ConversationKey: conversationKey,
DeviceName: deviceName,
AuthMode: authMode,
CreatedAt: now,
LastActivity: now,
subscriptions: make(map[string]*Subscription),
ctx: ctx,
cancel: cancel,
eventCh: make(chan *SessionEvent, 100),
}
}
// Context returns the session's context.
func (s *Session) Context() context.Context {
return s.ctx
}
// Close closes the session and cleans up resources.
func (s *Session) Close() {
s.cancel()
close(s.eventCh)
}
// Events returns the channel for receiving events destined for this session.
func (s *Session) Events() <-chan *SessionEvent {
return s.eventCh
}
// SendEvent sends an event to the session's event channel.
func (s *Session) SendEvent(ev *SessionEvent) bool {
select {
case s.eventCh <- ev:
return true
case <-s.ctx.Done():
return false
default:
// Channel full, drop event
return false
}
}
// Touch updates the last activity timestamp.
func (s *Session) Touch() {
s.LastActivity = time.Now()
}
// IsExpired checks if the session has been inactive too long.
func (s *Session) IsExpired(timeout time.Duration) bool {
return time.Since(s.LastActivity) > timeout
}
// AddSubscription adds a new subscription to the session.
func (s *Session) AddSubscription(subID string) error {
s.subMu.Lock()
defer s.subMu.Unlock()
if len(s.subscriptions) >= DefaultMaxSubscriptions {
return ErrTooManySubscriptions
}
s.subscriptions[subID] = &Subscription{
ID: subID,
CreatedAt: time.Now(),
}
return nil
}
// RemoveSubscription removes a subscription from the session.
func (s *Session) RemoveSubscription(subID string) {
s.subMu.Lock()
defer s.subMu.Unlock()
delete(s.subscriptions, subID)
}
// GetSubscription returns a subscription by ID.
func (s *Session) GetSubscription(subID string) *Subscription {
s.subMu.RLock()
defer s.subMu.RUnlock()
return s.subscriptions[subID]
}
// HasSubscription checks if a subscription exists.
func (s *Session) HasSubscription(subID string) bool {
s.subMu.RLock()
defer s.subMu.RUnlock()
_, ok := s.subscriptions[subID]
return ok
}
// SubscriptionCount returns the number of active subscriptions.
func (s *Session) SubscriptionCount() int {
s.subMu.RLock()
defer s.subMu.RUnlock()
return len(s.subscriptions)
}
// MarkEOSE marks a subscription as having sent EOSE.
func (s *Session) MarkEOSE(subID string) {
s.subMu.Lock()
defer s.subMu.Unlock()
if sub, ok := s.subscriptions[subID]; ok {
sub.EOSESent = true
}
}
// IncrementEventCount increments the event count for a subscription.
func (s *Session) IncrementEventCount(subID string) {
s.subMu.Lock()
defer s.subMu.Unlock()
if sub, ok := s.subscriptions[subID]; ok {
sub.EventCount++
}
}
// SessionManager manages multiple NRC sessions.
type SessionManager struct {
sessions map[string]*Session
mu sync.RWMutex
timeout time.Duration
}
// NewSessionManager creates a new session manager.
func NewSessionManager(timeout time.Duration) *SessionManager {
if timeout == 0 {
timeout = DefaultSessionTimeout
}
return &SessionManager{
sessions: make(map[string]*Session),
timeout: timeout,
}
}
// Get returns a session by ID.
func (m *SessionManager) Get(sessionID string) *Session {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessions[sessionID]
}
// GetOrCreate gets an existing session or creates a new one.
func (m *SessionManager) GetOrCreate(sessionID string, clientPubkey, conversationKey []byte, authMode AuthMode, deviceName string) *Session {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.Touch()
return session
}
session := NewSession(sessionID, clientPubkey, conversationKey, authMode, deviceName)
m.sessions[sessionID] = session
return session
}
// Remove removes a session.
func (m *SessionManager) Remove(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.Close()
delete(m.sessions, sessionID)
}
}
// CleanupExpired removes expired sessions.
func (m *SessionManager) CleanupExpired() int {
m.mu.Lock()
defer m.mu.Unlock()
var removed int
for id, session := range m.sessions {
if session.IsExpired(m.timeout) {
session.Close()
delete(m.sessions, id)
removed++
}
}
return removed
}
// Count returns the number of active sessions.
func (m *SessionManager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.sessions)
}
// Close closes all sessions.
func (m *SessionManager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
for _, session := range m.sessions {
session.Close()
}
m.sessions = make(map[string]*Session)
}
// RequestMessage represents a parsed NRC request message.
type RequestMessage struct {
Type string // EVENT, REQ, CLOSE, AUTH, COUNT
Payload []any
}
// ResponseMessage represents an NRC response message to be sent.
type ResponseMessage struct {
Type string // EVENT, OK, EOSE, NOTICE, CLOSED, COUNT, AUTH
Payload []any
}
// ParseRequestContent parses the decrypted content of an NRC request.
func ParseRequestContent(content []byte) (*RequestMessage, error) {
// Content format: {"type": "EVENT|REQ|...", "payload": [...]}
// Parse as generic JSON
var msg struct {
Type string `json:"type"`
Payload []any `json:"payload"`
}
if err := json.Unmarshal(content, &msg); err != nil {
return nil, err
}
if msg.Type == "" {
return nil, ErrInvalidMessageType
}
return &RequestMessage{
Type: msg.Type,
Payload: msg.Payload,
}, nil
}
// MarshalResponseContent marshals an NRC response for encryption.
func MarshalResponseContent(resp *ResponseMessage) ([]byte, error) {
msg := struct {
Type string `json:"type"`
Payload []any `json:"payload"`
}{
Type: resp.Type,
Payload: resp.Payload,
}
return json.Marshal(msg)
}

206
pkg/protocol/nrc/uri.go Normal file
View File

@@ -0,0 +1,206 @@
package nrc
import (
"errors"
"net/url"
"git.mleku.dev/mleku/nostr/crypto/encryption"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/interfaces/signer"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"lol.mleku.dev/chk"
)
// AuthMode defines the authentication mode for NRC connections.
type AuthMode int
const (
// AuthModeSecret uses a shared secret for authentication.
AuthModeSecret AuthMode = iota
// AuthModeCAT uses Cashu Access Tokens for authentication.
AuthModeCAT
)
// ConnectionURI represents a parsed nostr+relayconnect:// URI.
type ConnectionURI struct {
// RelayPubkey is the public key of the private relay (32 bytes).
RelayPubkey []byte
// RendezvousRelay is the WebSocket URL of the public relay.
RendezvousRelay string
// AuthMode indicates whether to use secret or CAT authentication.
AuthMode AuthMode
// DeviceName is an optional human-readable device identifier.
DeviceName string
// Secret-based authentication fields
clientSecretKey signer.I
conversationKey []byte
// CAT-based authentication fields
MintURL string
}
// GetClientSigner returns the signer derived from the secret (secret-based auth only).
func (c *ConnectionURI) GetClientSigner() signer.I {
return c.clientSecretKey
}
// GetConversationKey returns the NIP-44 conversation key (secret-based auth only).
func (c *ConnectionURI) GetConversationKey() []byte {
return c.conversationKey
}
// ParseConnectionURI parses a nostr+relayconnect:// URI.
//
// Secret-based URI format:
//
// nostr+relayconnect://<relay-pubkey>?relay=<rendezvous-relay>&secret=<client-secret>[&name=<device-name>]
//
// CAT-based URI format:
//
// nostr+relayconnect://<relay-pubkey>?relay=<rendezvous-relay>&auth=cat&mint=<mint-url>
func ParseConnectionURI(nrcURI string) (conn *ConnectionURI, err error) {
var p *url.URL
if p, err = url.Parse(nrcURI); chk.E(err) {
return
}
if p == nil {
err = errors.New("invalid uri")
return
}
conn = &ConnectionURI{}
// Validate scheme
if p.Scheme != "nostr+relayconnect" {
err = errors.New("incorrect scheme: expected nostr+relayconnect")
return
}
// Parse relay pubkey from host
if conn.RelayPubkey, err = hex.Dec(p.Host); chk.E(err) {
err = errors.New("invalid relay public key")
return
}
if len(conn.RelayPubkey) != 32 {
err = errors.New("relay public key must be 32 bytes")
return
}
query := p.Query()
// Parse rendezvous relay URL (required)
relayParam := query.Get("relay")
if relayParam == "" {
err = errors.New("missing relay parameter")
return
}
conn.RendezvousRelay = relayParam
// Parse optional device name
conn.DeviceName = query.Get("name")
// Determine auth mode
authParam := query.Get("auth")
if authParam == "cat" {
conn.AuthMode = AuthModeCAT
// Parse mint URL for CAT auth
conn.MintURL = query.Get("mint")
if conn.MintURL == "" {
err = errors.New("missing mint parameter for CAT auth")
return
}
} else {
conn.AuthMode = AuthModeSecret
// Parse secret for secret-based auth
secret := query.Get("secret")
if secret == "" {
err = errors.New("missing secret parameter")
return
}
var secretBytes []byte
if secretBytes, err = hex.Dec(secret); chk.E(err) {
err = errors.New("invalid secret: must be hex-encoded")
return
}
if len(secretBytes) != 32 {
err = errors.New("secret must be 32 bytes")
return
}
// Create signer from secret
var clientKey *p8k.Signer
if clientKey, err = p8k.New(); chk.E(err) {
return
}
if err = clientKey.InitSec(secretBytes); chk.E(err) {
return
}
conn.clientSecretKey = clientKey
// Generate conversation key using NIP-44 key derivation
if conn.conversationKey, err = encryption.GenerateConversationKey(
clientKey.Sec(),
conn.RelayPubkey,
); chk.E(err) {
return
}
}
return
}
// GenerateConnectionURI creates a new NRC connection URI with a random secret.
func GenerateConnectionURI(relayPubkey []byte, rendezvousRelay string, deviceName string) (uri string, secret []byte, err error) {
if len(relayPubkey) != 32 {
err = errors.New("relay public key must be 32 bytes")
return
}
// Generate random 32-byte secret
var clientKey *p8k.Signer
if clientKey, err = p8k.New(); chk.E(err) {
return
}
if err = clientKey.Generate(); chk.E(err) {
return
}
secret = clientKey.Sec()
// Build URI
u := &url.URL{
Scheme: "nostr+relayconnect",
Host: string(hex.Enc(relayPubkey)),
}
q := u.Query()
q.Set("relay", rendezvousRelay)
q.Set("secret", string(hex.Enc(secret)))
if deviceName != "" {
q.Set("name", deviceName)
}
u.RawQuery = q.Encode()
uri = u.String()
return
}
// GenerateCATConnectionURI creates a new NRC connection URI for CAT authentication.
func GenerateCATConnectionURI(relayPubkey []byte, rendezvousRelay string, mintURL string) (uri string, err error) {
if len(relayPubkey) != 32 {
err = errors.New("relay public key must be 32 bytes")
return
}
// Build URI
u := &url.URL{
Scheme: "nostr+relayconnect",
Host: string(hex.Enc(relayPubkey)),
}
q := u.Query()
q.Set("relay", rendezvousRelay)
q.Set("auth", "cat")
q.Set("mint", mintURL)
u.RawQuery = q.Encode()
uri = u.String()
return
}