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