You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
623 lines
16 KiB
623 lines
16 KiB
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() |
|
}
|
|
|