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

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()
}