Add NRC (Nostr Relay Connect) protocol and web UI (v0.48.9)
Some checks failed
Go / build-and-release (push) Has been cancelled
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:
623
pkg/protocol/nrc/bridge.go
Normal file
623
pkg/protocol/nrc/bridge.go
Normal 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: ×tamp.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
513
pkg/protocol/nrc/client.go
Normal 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: ×tamp.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))
|
||||
}
|
||||
24
pkg/protocol/nrc/errors.go
Normal file
24
pkg/protocol/nrc/errors.go
Normal 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")
|
||||
)
|
||||
371
pkg/protocol/nrc/nrc_test.go
Normal file
371
pkg/protocol/nrc/nrc_test.go
Normal 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
322
pkg/protocol/nrc/session.go
Normal 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
206
pkg/protocol/nrc/uri.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user