aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-02-19 21:24:07 -0800
committerClawd <ai@clawd.bot>2026-02-19 21:24:07 -0800
commit5778c288e1f61af9dee23967c62871c4c31b0feb (patch)
tree5838473fc3be94d64176a1b6303def7da933b03e
parent6f3902d09504838e4e486825f073df971c412595 (diff)
Add bech32 encoding (npub/nsec for Nostr)
-rw-r--r--bech32.go212
-rw-r--r--bech32_test.go193
2 files changed, 405 insertions, 0 deletions
diff --git a/bech32.go b/bech32.go
new file mode 100644
index 0000000..835156d
--- /dev/null
+++ b/bech32.go
@@ -0,0 +1,212 @@
1package secp256k1
2
3import (
4 "fmt"
5 "math/big"
6 "strings"
7)
8
9// Bech32 alphabet (no 1, b, i, o to avoid confusion)
10const bech32Alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
11
12// bech32Polymod computes the Bech32 checksum
13func bech32Polymod(values []int) int {
14 gen := []int{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
15 chk := 1
16 for _, v := range values {
17 top := chk >> 25
18 chk = (chk&0x1ffffff)<<5 ^ v
19 for i := 0; i < 5; i++ {
20 if (top>>i)&1 == 1 {
21 chk ^= gen[i]
22 }
23 }
24 }
25 return chk
26}
27
28// bech32HRPExpand expands the human-readable part for checksum
29func bech32HRPExpand(hrp string) []int {
30 result := make([]int, len(hrp)*2+1)
31 for i, c := range hrp {
32 result[i] = int(c) >> 5
33 }
34 result[len(hrp)] = 0
35 for i, c := range hrp {
36 result[len(hrp)+1+i] = int(c) & 31
37 }
38 return result
39}
40
41// bech32CreateChecksum creates a 6-character checksum
42func bech32CreateChecksum(hrp string, data []int) []int {
43 values := append(bech32HRPExpand(hrp), data...)
44 values = append(values, []int{0, 0, 0, 0, 0, 0}...)
45 polymod := bech32Polymod(values) ^ 1
46 checksum := make([]int, 6)
47 for i := 0; i < 6; i++ {
48 checksum[i] = (polymod >> (5 * (5 - i))) & 31
49 }
50 return checksum
51}
52
53// bech32VerifyChecksum verifies a bech32 checksum
54func bech32VerifyChecksum(hrp string, data []int) bool {
55 return bech32Polymod(append(bech32HRPExpand(hrp), data...)) == 1
56}
57
58// convertBits converts between bit groups
59func convertBits(data []byte, fromBits, toBits int, pad bool) ([]int, error) {
60 acc := 0
61 bits := 0
62 result := []int{}
63 maxv := (1 << toBits) - 1
64
65 for _, value := range data {
66 acc = (acc << fromBits) | int(value)
67 bits += fromBits
68 for bits >= toBits {
69 bits -= toBits
70 result = append(result, (acc>>bits)&maxv)
71 }
72 }
73
74 if pad {
75 if bits > 0 {
76 result = append(result, (acc<<(toBits-bits))&maxv)
77 }
78 } else if bits >= fromBits || ((acc<<(toBits-bits))&maxv) != 0 {
79 return nil, fmt.Errorf("invalid padding")
80 }
81
82 return result, nil
83}
84
85// Bech32Encode encodes data with a human-readable prefix
86func Bech32Encode(hrp string, data []byte) (string, error) {
87 // Convert 8-bit bytes to 5-bit groups
88 values, err := convertBits(data, 8, 5, true)
89 if err != nil {
90 return "", err
91 }
92
93 // Add checksum
94 checksum := bech32CreateChecksum(hrp, values)
95 combined := append(values, checksum...)
96
97 // Build result string
98 var result strings.Builder
99 result.WriteString(hrp)
100 result.WriteByte('1')
101 for _, v := range combined {
102 result.WriteByte(bech32Alphabet[v])
103 }
104
105 return result.String(), nil
106}
107
108// Bech32Decode decodes a bech32 string
109func Bech32Decode(s string) (string, []byte, error) {
110 // Find separator
111 pos := strings.LastIndex(s, "1")
112 if pos < 1 || pos+7 > len(s) {
113 return "", nil, fmt.Errorf("invalid bech32: separator position")
114 }
115
116 // Split HRP and data
117 hrp := strings.ToLower(s[:pos])
118 dataStr := strings.ToLower(s[pos+1:])
119
120 // Decode data characters
121 data := make([]int, len(dataStr))
122 for i, c := range dataStr {
123 idx := strings.IndexRune(bech32Alphabet, c)
124 if idx < 0 {
125 return "", nil, fmt.Errorf("invalid bech32: character %c", c)
126 }
127 data[i] = idx
128 }
129
130 // Verify checksum
131 if !bech32VerifyChecksum(hrp, data) {
132 return "", nil, fmt.Errorf("invalid bech32: checksum")
133 }
134
135 // Remove checksum and convert back to 8-bit
136 values := data[:len(data)-6]
137 bytes, err := convertBitsToBytes(values)
138 if err != nil {
139 return "", nil, err
140 }
141
142 return hrp, bytes, nil
143}
144
145// convertBitsToBytes converts 5-bit groups back to bytes
146func convertBitsToBytes(data []int) ([]byte, error) {
147 acc := 0
148 bits := 0
149 result := []byte{}
150
151 for _, value := range data {
152 acc = (acc << 5) | value
153 bits += 5
154 for bits >= 8 {
155 bits -= 8
156 result = append(result, byte((acc>>bits)&0xff))
157 }
158 }
159
160 return result, nil
161}
162
163// === Nostr-specific helpers ===
164
165// Nsec returns the private key as a Nostr nsec string
166func (priv *PrivateKey) Nsec() string {
167 encoded, _ := Bech32Encode("nsec", priv.Bytes())
168 return encoded
169}
170
171// Npub returns the public key as a Nostr npub string (x-only)
172func (pub *PublicKey) Npub() string {
173 encoded, _ := Bech32Encode("npub", pub.XOnlyBytes())
174 return encoded
175}
176
177// PrivateKeyFromNsec parses an nsec string
178func PrivateKeyFromNsec(nsec string) (*PrivateKey, error) {
179 hrp, data, err := Bech32Decode(nsec)
180 if err != nil {
181 return nil, fmt.Errorf("invalid nsec: %w", err)
182 }
183 if hrp != "nsec" {
184 return nil, fmt.Errorf("invalid nsec: wrong prefix %q", hrp)
185 }
186 if len(data) != 32 {
187 return nil, fmt.Errorf("invalid nsec: wrong length %d", len(data))
188 }
189 return NewPrivateKeyFromBytes(data)
190}
191
192// PublicKeyFromNpub parses an npub string
193func PublicKeyFromNpub(npub string) (*PublicKey, error) {
194 hrp, data, err := Bech32Decode(npub)
195 if err != nil {
196 return nil, fmt.Errorf("invalid npub: %w", err)
197 }
198 if hrp != "npub" {
199 return nil, fmt.Errorf("invalid npub: wrong prefix %q", hrp)
200 }
201 if len(data) != 32 {
202 return nil, fmt.Errorf("invalid npub: wrong length %d", len(data))
203 }
204
205 // Lift x-coordinate to full point
206 point, err := liftX(new(big.Int).SetBytes(data))
207 if err != nil {
208 return nil, fmt.Errorf("invalid npub: %w", err)
209 }
210
211 return &PublicKey{Point: point}, nil
212}
diff --git a/bech32_test.go b/bech32_test.go
new file mode 100644
index 0000000..276d998
--- /dev/null
+++ b/bech32_test.go
@@ -0,0 +1,193 @@
1package secp256k1
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestBech32EncodeBasic(t *testing.T) {
9 data := []byte{0x00, 0x01, 0x02}
10 encoded, err := Bech32Encode("test", data)
11 if err != nil {
12 t.Fatalf("encoding failed: %v", err)
13 }
14
15 // Should start with hrp + "1"
16 if !strings.HasPrefix(encoded, "test1") {
17 t.Errorf("encoded should start with 'test1', got %s", encoded)
18 }
19}
20
21func TestBech32RoundTrip(t *testing.T) {
22 data := []byte{0xde, 0xad, 0xbe, 0xef}
23 encoded, err := Bech32Encode("test", data)
24 if err != nil {
25 t.Fatalf("encoding failed: %v", err)
26 }
27
28 hrp, decoded, err := Bech32Decode(encoded)
29 if err != nil {
30 t.Fatalf("decoding failed: %v", err)
31 }
32
33 if hrp != "test" {
34 t.Errorf("hrp mismatch: got %s, want test", hrp)
35 }
36
37 if len(decoded) != len(data) {
38 t.Fatalf("length mismatch: got %d, want %d", len(decoded), len(data))
39 }
40
41 for i := range data {
42 if decoded[i] != data[i] {
43 t.Errorf("byte %d mismatch: got %x, want %x", i, decoded[i], data[i])
44 }
45 }
46}
47
48func TestBech32DecodeInvalidChecksum(t *testing.T) {
49 // Valid encoding, then corrupt it
50 data := []byte{0x01, 0x02, 0x03}
51 encoded, _ := Bech32Encode("test", data)
52
53 // Corrupt last character
54 corrupted := encoded[:len(encoded)-1] + "q"
55
56 _, _, err := Bech32Decode(corrupted)
57 if err == nil {
58 t.Error("should reject invalid checksum")
59 }
60}
61
62func TestBech32DecodeInvalidCharacter(t *testing.T) {
63 _, _, err := Bech32Decode("test1invalid!")
64 if err == nil {
65 t.Error("should reject invalid character")
66 }
67}
68
69func TestNsecEncode(t *testing.T) {
70 priv, _ := NewPrivateKeyFromHex("0000000000000000000000000000000000000000000000000000000000000001")
71 nsec := priv.Nsec()
72
73 if !strings.HasPrefix(nsec, "nsec1") {
74 t.Errorf("nsec should start with 'nsec1', got %s", nsec)
75 }
76}
77
78func TestNpubEncode(t *testing.T) {
79 priv, _ := NewPrivateKeyFromHex("0000000000000000000000000000000000000000000000000000000000000001")
80 pub := priv.PublicKey()
81 npub := pub.Npub()
82
83 if !strings.HasPrefix(npub, "npub1") {
84 t.Errorf("npub should start with 'npub1', got %s", npub)
85 }
86}
87
88func TestNsecRoundTrip(t *testing.T) {
89 priv1, _ := GeneratePrivateKey()
90 nsec := priv1.Nsec()
91
92 priv2, err := PrivateKeyFromNsec(nsec)
93 if err != nil {
94 t.Fatalf("failed to parse nsec: %v", err)
95 }
96
97 if priv1.D.Cmp(priv2.D) != 0 {
98 t.Error("private key should survive nsec round-trip")
99 }
100}
101
102func TestNpubRoundTrip(t *testing.T) {
103 priv, _ := GeneratePrivateKey()
104 pub1 := priv.PublicKey()
105 npub := pub1.Npub()
106
107 pub2, err := PublicKeyFromNpub(npub)
108 if err != nil {
109 t.Fatalf("failed to parse npub: %v", err)
110 }
111
112 // X coordinates should match (y might differ in sign)
113 if pub1.Point.x.value.Cmp(pub2.Point.x.value) != 0 {
114 t.Error("public key x should survive npub round-trip")
115 }
116}
117
118func TestPrivateKeyFromNsecInvalid(t *testing.T) {
119 // Wrong prefix
120 _, err := PrivateKeyFromNsec("npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3xjaeh")
121 if err == nil {
122 t.Error("should reject npub as nsec")
123 }
124}
125
126func TestPublicKeyFromNpubInvalid(t *testing.T) {
127 // Wrong prefix
128 _, err := PublicKeyFromNpub("nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq0dcpx3")
129 if err == nil {
130 t.Error("should reject nsec as npub")
131 }
132}
133
134// Test with known private key
135func TestKnownNostrKeyPair(t *testing.T) {
136 // Private key = 1, should give G as public key
137 priv, _ := NewPrivateKeyFromHex("0000000000000000000000000000000000000000000000000000000000000001")
138
139 nsec := priv.Nsec()
140 // Verify it starts with nsec and round-trips
141 if nsec[:5] != "nsec1" {
142 t.Errorf("nsec should start with nsec1, got %s", nsec)
143 }
144
145 // Parse it back
146 priv2, err := PrivateKeyFromNsec(nsec)
147 if err != nil {
148 t.Fatalf("failed to parse nsec: %v", err)
149 }
150 if priv.D.Cmp(priv2.D) != 0 {
151 t.Error("nsec round-trip failed")
152 }
153
154 // Public key should be G
155 pub := priv.PublicKey()
156 npub := pub.Npub()
157
158 if npub[:5] != "npub1" {
159 t.Errorf("npub should start with npub1, got %s", npub)
160 }
161
162 // Parse it back and verify x matches G
163 pub2, err := PublicKeyFromNpub(npub)
164 if err != nil {
165 t.Fatalf("failed to parse npub: %v", err)
166 }
167 if pub2.Point.x.value.Cmp(Gx) != 0 {
168 t.Error("npub should decode to G.x")
169 }
170}
171
172func TestSignAndVerifyWithNostrKeys(t *testing.T) {
173 // Create keys
174 priv, _ := GeneratePrivateKey()
175 nsec := priv.Nsec()
176 npub := priv.PublicKey().Npub()
177
178 // Parse them back
179 priv2, _ := PrivateKeyFromNsec(nsec)
180 pub2, _ := PublicKeyFromNpub(npub)
181
182 // Sign with parsed private key
183 message := []byte("hello nostr")
184 sig, err := Sign(priv2, message)
185 if err != nil {
186 t.Fatalf("signing failed: %v", err)
187 }
188
189 // Verify with parsed public key
190 if !Verify(pub2, message, sig) {
191 t.Error("signature should verify with parsed keys")
192 }
193}