From 5778c288e1f61af9dee23967c62871c4c31b0feb Mon Sep 17 00:00:00 2001 From: Clawd Date: Thu, 19 Feb 2026 21:24:07 -0800 Subject: Add bech32 encoding (npub/nsec for Nostr) --- bech32.go | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 bech32.go (limited to 'bech32.go') diff --git a/bech32.go b/bech32.go new file mode 100644 index 0000000..835156d --- /dev/null +++ b/bech32.go @@ -0,0 +1,212 @@ +package secp256k1 + +import ( + "fmt" + "math/big" + "strings" +) + +// Bech32 alphabet (no 1, b, i, o to avoid confusion) +const bech32Alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + +// bech32Polymod computes the Bech32 checksum +func bech32Polymod(values []int) int { + gen := []int{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3} + chk := 1 + for _, v := range values { + top := chk >> 25 + chk = (chk&0x1ffffff)<<5 ^ v + for i := 0; i < 5; i++ { + if (top>>i)&1 == 1 { + chk ^= gen[i] + } + } + } + return chk +} + +// bech32HRPExpand expands the human-readable part for checksum +func bech32HRPExpand(hrp string) []int { + result := make([]int, len(hrp)*2+1) + for i, c := range hrp { + result[i] = int(c) >> 5 + } + result[len(hrp)] = 0 + for i, c := range hrp { + result[len(hrp)+1+i] = int(c) & 31 + } + return result +} + +// bech32CreateChecksum creates a 6-character checksum +func bech32CreateChecksum(hrp string, data []int) []int { + values := append(bech32HRPExpand(hrp), data...) + values = append(values, []int{0, 0, 0, 0, 0, 0}...) + polymod := bech32Polymod(values) ^ 1 + checksum := make([]int, 6) + for i := 0; i < 6; i++ { + checksum[i] = (polymod >> (5 * (5 - i))) & 31 + } + return checksum +} + +// bech32VerifyChecksum verifies a bech32 checksum +func bech32VerifyChecksum(hrp string, data []int) bool { + return bech32Polymod(append(bech32HRPExpand(hrp), data...)) == 1 +} + +// convertBits converts between bit groups +func convertBits(data []byte, fromBits, toBits int, pad bool) ([]int, error) { + acc := 0 + bits := 0 + result := []int{} + maxv := (1 << toBits) - 1 + + for _, value := range data { + acc = (acc << fromBits) | int(value) + bits += fromBits + for bits >= toBits { + bits -= toBits + result = append(result, (acc>>bits)&maxv) + } + } + + if pad { + if bits > 0 { + result = append(result, (acc<<(toBits-bits))&maxv) + } + } else if bits >= fromBits || ((acc<<(toBits-bits))&maxv) != 0 { + return nil, fmt.Errorf("invalid padding") + } + + return result, nil +} + +// Bech32Encode encodes data with a human-readable prefix +func Bech32Encode(hrp string, data []byte) (string, error) { + // Convert 8-bit bytes to 5-bit groups + values, err := convertBits(data, 8, 5, true) + if err != nil { + return "", err + } + + // Add checksum + checksum := bech32CreateChecksum(hrp, values) + combined := append(values, checksum...) + + // Build result string + var result strings.Builder + result.WriteString(hrp) + result.WriteByte('1') + for _, v := range combined { + result.WriteByte(bech32Alphabet[v]) + } + + return result.String(), nil +} + +// Bech32Decode decodes a bech32 string +func Bech32Decode(s string) (string, []byte, error) { + // Find separator + pos := strings.LastIndex(s, "1") + if pos < 1 || pos+7 > len(s) { + return "", nil, fmt.Errorf("invalid bech32: separator position") + } + + // Split HRP and data + hrp := strings.ToLower(s[:pos]) + dataStr := strings.ToLower(s[pos+1:]) + + // Decode data characters + data := make([]int, len(dataStr)) + for i, c := range dataStr { + idx := strings.IndexRune(bech32Alphabet, c) + if idx < 0 { + return "", nil, fmt.Errorf("invalid bech32: character %c", c) + } + data[i] = idx + } + + // Verify checksum + if !bech32VerifyChecksum(hrp, data) { + return "", nil, fmt.Errorf("invalid bech32: checksum") + } + + // Remove checksum and convert back to 8-bit + values := data[:len(data)-6] + bytes, err := convertBitsToBytes(values) + if err != nil { + return "", nil, err + } + + return hrp, bytes, nil +} + +// convertBitsToBytes converts 5-bit groups back to bytes +func convertBitsToBytes(data []int) ([]byte, error) { + acc := 0 + bits := 0 + result := []byte{} + + for _, value := range data { + acc = (acc << 5) | value + bits += 5 + for bits >= 8 { + bits -= 8 + result = append(result, byte((acc>>bits)&0xff)) + } + } + + return result, nil +} + +// === Nostr-specific helpers === + +// Nsec returns the private key as a Nostr nsec string +func (priv *PrivateKey) Nsec() string { + encoded, _ := Bech32Encode("nsec", priv.Bytes()) + return encoded +} + +// Npub returns the public key as a Nostr npub string (x-only) +func (pub *PublicKey) Npub() string { + encoded, _ := Bech32Encode("npub", pub.XOnlyBytes()) + return encoded +} + +// PrivateKeyFromNsec parses an nsec string +func PrivateKeyFromNsec(nsec string) (*PrivateKey, error) { + hrp, data, err := Bech32Decode(nsec) + if err != nil { + return nil, fmt.Errorf("invalid nsec: %w", err) + } + if hrp != "nsec" { + return nil, fmt.Errorf("invalid nsec: wrong prefix %q", hrp) + } + if len(data) != 32 { + return nil, fmt.Errorf("invalid nsec: wrong length %d", len(data)) + } + return NewPrivateKeyFromBytes(data) +} + +// PublicKeyFromNpub parses an npub string +func PublicKeyFromNpub(npub string) (*PublicKey, error) { + hrp, data, err := Bech32Decode(npub) + if err != nil { + return nil, fmt.Errorf("invalid npub: %w", err) + } + if hrp != "npub" { + return nil, fmt.Errorf("invalid npub: wrong prefix %q", hrp) + } + if len(data) != 32 { + return nil, fmt.Errorf("invalid npub: wrong length %d", len(data)) + } + + // Lift x-coordinate to full point + point, err := liftX(new(big.Int).SetBytes(data)) + if err != nil { + return nil, fmt.Errorf("invalid npub: %w", err) + } + + return &PublicKey{Point: point}, nil +} -- cgit v1.2.3