identity.go raw

   1  package bridge
   2  
   3  import (
   4  	"fmt"
   5  	"os"
   6  	"path/filepath"
   7  
   8  	"next.orly.dev/pkg/nostr/crypto/keys"
   9  	"next.orly.dev/pkg/nostr/encoders/bech32encoding"
  10  	"next.orly.dev/pkg/nostr/encoders/hex"
  11  	"next.orly.dev/pkg/nostr/interfaces/signer"
  12  	"next.orly.dev/pkg/nostr/interfaces/signer/p8k"
  13  )
  14  
  15  // IdentitySource describes where the bridge identity came from.
  16  type IdentitySource int
  17  
  18  const (
  19  	IdentityFromConfig IdentitySource = iota // NSEC provided via env/config
  20  	IdentityFromDB                           // Read from relay database
  21  	IdentityFromFile                         // Read from or generated to file
  22  )
  23  
  24  // ResolveIdentity resolves the bridge signer using a three-tier strategy:
  25  //
  26  //  1. Config NSEC (env var ORLY_BRIDGE_NSEC) — highest priority
  27  //  2. Database getter (monolithic mode — reads relay identity)
  28  //  3. File fallback (standalone mode — reads or generates bridge.nsec)
  29  //
  30  // The dbGetter parameter is a function that returns the relay identity secret
  31  // key from the database. Pass nil in standalone mode.
  32  func ResolveIdentity(nsecConfig string, dbGetter func() ([]byte, error), dataDir string) (signer.I, IdentitySource, error) {
  33  	// Tier 1: NSEC from config/environment
  34  	if nsecConfig != "" {
  35  		sign, err := signerFromNSEC(nsecConfig)
  36  		if err != nil {
  37  			return nil, 0, fmt.Errorf("config NSEC: %w", err)
  38  		}
  39  		return sign, IdentityFromConfig, nil
  40  	}
  41  
  42  	// Tier 2: Database (relay identity)
  43  	if dbGetter != nil {
  44  		sk, err := dbGetter()
  45  		if err == nil && len(sk) == 32 {
  46  			sign, err := signerFromSecretKey(sk)
  47  			if err != nil {
  48  				return nil, 0, fmt.Errorf("database identity: %w", err)
  49  			}
  50  			return sign, IdentityFromDB, nil
  51  		}
  52  		// Fall through to file if database fails
  53  	}
  54  
  55  	// Tier 3: File fallback
  56  	sign, err := identityFromFile(dataDir)
  57  	if err != nil {
  58  		return nil, 0, fmt.Errorf("file identity: %w", err)
  59  	}
  60  	return sign, IdentityFromFile, nil
  61  }
  62  
  63  // signerFromNSEC creates a signer from an nsec bech32 string or hex secret key.
  64  func signerFromNSEC(nsec string) (signer.I, error) {
  65  	var sk []byte
  66  	var err error
  67  
  68  	// Try nsec bech32 first
  69  	if len(nsec) > 4 && nsec[:4] == "nsec" {
  70  		sk, err = bech32encoding.NsecToBytes(nsec)
  71  		if err != nil {
  72  			return nil, fmt.Errorf("decode nsec: %w", err)
  73  		}
  74  	} else {
  75  		// Try hex
  76  		sk, err = hex.Dec(nsec)
  77  		if err != nil || len(sk) != 32 {
  78  			return nil, fmt.Errorf("invalid secret key (expected 32-byte hex or nsec bech32)")
  79  		}
  80  	}
  81  
  82  	return signerFromSecretKey(sk)
  83  }
  84  
  85  // signerFromSecretKey creates a signer from a raw 32-byte secret key.
  86  func signerFromSecretKey(sk []byte) (signer.I, error) {
  87  	sign, err := p8k.New()
  88  	if err != nil {
  89  		return nil, fmt.Errorf("create signer: %w", err)
  90  	}
  91  	if err = sign.InitSec(sk); err != nil {
  92  		return nil, fmt.Errorf("init signer: %w", err)
  93  	}
  94  	return sign, nil
  95  }
  96  
  97  // identityFromFile reads or generates the bridge identity from a file.
  98  func identityFromFile(dataDir string) (signer.I, error) {
  99  	nsecPath := filepath.Join(dataDir, "bridge.nsec")
 100  
 101  	// Try reading existing file
 102  	data, err := os.ReadFile(nsecPath)
 103  	if err == nil {
 104  		nsec := string(trimBytes(data))
 105  		if nsec != "" {
 106  			return signerFromNSEC(nsec)
 107  		}
 108  	}
 109  
 110  	// Generate new identity
 111  	sk, err := keys.GenerateSecretKey()
 112  	if err != nil {
 113  		return nil, fmt.Errorf("generate secret key: %w", err)
 114  	}
 115  	sign, err := signerFromSecretKey(sk)
 116  	if err != nil {
 117  		return nil, err
 118  	}
 119  
 120  	// Persist as nsec
 121  	nsec, err := bech32encoding.BinToNsec(sk)
 122  	if err != nil {
 123  		return nil, fmt.Errorf("encode nsec: %w", err)
 124  	}
 125  
 126  	if err := os.MkdirAll(dataDir, 0700); err != nil {
 127  		return nil, fmt.Errorf("create data dir: %w", err)
 128  	}
 129  	if err := os.WriteFile(nsecPath, []byte(nsec), 0600); err != nil {
 130  		return nil, fmt.Errorf("write nsec file: %w", err)
 131  	}
 132  
 133  	return sign, nil
 134  }
 135  
 136  // trimBytes trims whitespace and newlines from byte slices.
 137  func trimBytes(b []byte) []byte {
 138  	for len(b) > 0 && (b[len(b)-1] == '\n' || b[len(b)-1] == '\r' || b[len(b)-1] == ' ' || b[len(b)-1] == '\t') {
 139  		b = b[:len(b)-1]
 140  	}
 141  	return b
 142  }
 143