From fb8593044dd3517bb500e747b9d8d91e7112fbeb Mon Sep 17 00:00:00 2001 From: mleku Date: Sun, 7 Sep 2025 08:33:05 +0100 Subject: [PATCH] improve fast equal API, add bech32 encoding handling --- pkg/encoders/bech32encoding/doc.go | 6 + pkg/encoders/bech32encoding/keys.go | 251 ++++++++++++++++ pkg/encoders/bech32encoding/keys_test.go | 114 ++++++++ pkg/encoders/bech32encoding/nip19.go | 256 ++++++++++++++++ pkg/encoders/bech32encoding/nip19_test.go | 275 ++++++++++++++++++ .../bech32encoding/pointers/pointers.go | 31 ++ pkg/encoders/bech32encoding/tlv/tlv.go | 41 +++ pkg/encoders/tag/atag/atag.go | 6 +- pkg/encoders/tag/atag/atag_test.go | 2 +- pkg/utils/fastequal.go | 8 +- 10 files changed, 983 insertions(+), 7 deletions(-) create mode 100644 pkg/encoders/bech32encoding/doc.go create mode 100644 pkg/encoders/bech32encoding/keys.go create mode 100644 pkg/encoders/bech32encoding/keys_test.go create mode 100644 pkg/encoders/bech32encoding/nip19.go create mode 100644 pkg/encoders/bech32encoding/nip19_test.go create mode 100644 pkg/encoders/bech32encoding/pointers/pointers.go create mode 100644 pkg/encoders/bech32encoding/tlv/tlv.go diff --git a/pkg/encoders/bech32encoding/doc.go b/pkg/encoders/bech32encoding/doc.go new file mode 100644 index 0000000..ca37c76 --- /dev/null +++ b/pkg/encoders/bech32encoding/doc.go @@ -0,0 +1,6 @@ +// Package bech32encoding implements NIP-19 entities, which are bech32 encoded +// data that describes nostr data types. +// +// These are not just identifiers of events and users, but also include things +// like relay hints where to find events. +package bech32encoding diff --git a/pkg/encoders/bech32encoding/keys.go b/pkg/encoders/bech32encoding/keys.go new file mode 100644 index 0000000..cddedcd --- /dev/null +++ b/pkg/encoders/bech32encoding/keys.go @@ -0,0 +1,251 @@ +package bech32encoding + +import ( + "bytes" + + "crypto.orly/ec" + "crypto.orly/ec/bech32" + "crypto.orly/ec/schnorr" + "crypto.orly/ec/secp256k1" + "encoders.orly/hex" + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + "utils.orly" +) + +const ( + // MinKeyStringLen is 56 because Bech32 needs 52 characters plus 4 for the HRP, + // any string shorter than this cannot be a nostr key. + MinKeyStringLen = 56 + // HexKeyLen is the length of a nostr key in hexadecimal. + HexKeyLen = 64 + // Bech32HRPLen is the length of the standard nostr keys, nsec and npub. + Bech32HRPLen = 4 +) + +var ( + // SecHRP is the standard Human Readable Prefix (HRP) for a nostr secret key in bech32 encoding - nsec + SecHRP = []byte("nsec") + // PubHRP is the standard Human Readable Prefix (HRP) for a nostr public key in bech32 encoding - nsec + PubHRP = []byte("npub") +) + +// ConvertForBech32 performs the bit expansion required for encoding into Bech32. +func ConvertForBech32(b8 []byte) (b5 []byte, err error) { + return bech32.ConvertBits( + b8, 8, 5, + true, + ) +} + +// ConvertFromBech32 collapses together the bit expanded 5 bit numbers encoded in bech32. +func ConvertFromBech32(b5 []byte) (b8 []byte, err error) { + return bech32.ConvertBits( + b5, 5, 8, + true, + ) +} + +// SecretKeyToNsec encodes an secp256k1 secret key as a Bech32 string (nsec). +func SecretKeyToNsec(sk *secp256k1.SecretKey) (encoded []byte, err error) { + var b5 []byte + if b5, err = ConvertForBech32(sk.Serialize()); chk.E(err) { + return + } + return bech32.Encode(SecHRP, b5) +} + +// PublicKeyToNpub encodes a public key as a bech32 string (npub). +func PublicKeyToNpub(pk *secp256k1.PublicKey) (encoded []byte, err error) { + var bits5 []byte + pubKeyBytes := schnorr.SerializePubKey(pk) + if bits5, err = ConvertForBech32(pubKeyBytes); chk.E(err) { + return + } + return bech32.Encode(PubHRP, bits5) +} + +// NsecToSecretKey decodes a nostr secret key (nsec) and returns the secp256k1 +// secret key. +func NsecToSecretKey(encoded []byte) (sk *secp256k1.SecretKey, err error) { + var b8 []byte + if b8, err = NsecToBytes(encoded); chk.E(err) { + return + } + sk = secp256k1.SecKeyFromBytes(b8) + return +} + +// NsecToBytes converts a nostr bech32 encoded secret key to raw bytes. +func NsecToBytes(encoded []byte) (sk []byte, err error) { + var b5, hrp []byte + if hrp, b5, err = bech32.Decode(encoded); chk.E(err) { + return + } + if !utils.FastEqual(hrp, SecHRP) { + err = log.E.Err( + "wrong human readable part, got '%s' want '%s'", + hrp, SecHRP, + ) + return + } + if sk, err = ConvertFromBech32(b5); chk.E(err) { + return + } + sk = sk[:secp256k1.SecKeyBytesLen] + return +} + +// NpubToBytes converts a bech32 encoded public key to raw bytes. +func NpubToBytes(encoded []byte) (pk []byte, err error) { + var b5, hrp []byte + if hrp, b5, err = bech32.Decode(encoded); chk.E(err) { + return + } + if !utils.FastEqual(hrp, PubHRP) { + err = log.E.Err( + "wrong human readable part, got '%s' want '%s'", + hrp, SecHRP, + ) + return + } + if pk, err = ConvertFromBech32(b5); chk.E(err) { + return + } + pk = pk[:schnorr.PubKeyBytesLen] + return +} + +// NpubToPublicKey decodes an nostr public key (npub) and returns an secp256k1 +// public key. +func NpubToPublicKey(encoded []byte) (pk *secp256k1.PublicKey, err error) { + var b5, b8, hrp []byte + if hrp, b5, err = bech32.Decode(encoded); chk.E(err) { + err = log.E.Err("ERROR: '%s'", err) + return + } + if !utils.FastEqual(hrp, PubHRP) { + err = log.E.Err( + "wrong human readable part, got '%s' want '%s'", + hrp, PubHRP, + ) + return + } + if b8, err = ConvertFromBech32(b5); chk.E(err) { + return + } + + return schnorr.ParsePubKey(b8[:schnorr.PubKeyBytesLen]) +} + +// HexToPublicKey decodes a string that should be a 64 character long hex +// encoded public key into a btcec.PublicKey that can be used to verify a +// signature or encode to Bech32. +func HexToPublicKey(pk string) (p *btcec.PublicKey, err error) { + if len(pk) != HexKeyLen { + err = log.E.Err( + "secret key is %d bytes, must be %d", len(pk), + HexKeyLen, + ) + return + } + var pb []byte + if pb, err = hex.Dec(pk); chk.D(err) { + return + } + if p, err = schnorr.ParsePubKey(pb); chk.D(err) { + return + } + return +} + +func NpubOrHexToPublicKey(encoded []byte) (pk *btcec.PublicKey, err error) { + if !bytes.HasPrefix([]byte("npub"), encoded) && len(encoded) == HexKeyLen { + return HexToPublicKey(string(encoded)) + } + return NpubToPublicKey(encoded) +} + +// HexToSecretKey decodes a string that should be a 64 character long hex +// encoded public key into a btcec.PublicKey that can be used to verify a +// signature or encode to Bech32. +func HexToSecretKey(sk []byte) (s *btcec.SecretKey, err error) { + if len(sk) != HexKeyLen { + err = log.E.Err( + "secret key is %d bytes, must be %d", len(sk), + HexKeyLen, + ) + return + } + pb := make([]byte, schnorr.PubKeyBytesLen) + if _, err = hex.DecBytes(pb, sk); chk.D(err) { + return + } + if s = secp256k1.SecKeyFromBytes(pb); chk.D(err) { + return + } + return +} + +// HexToNpub converts a raw 64 character hex encoded public key (as used in +// standard nostr json events) to a bech32 encoded npub. +func HexToNpub(publicKeyHex []byte) (s []byte, err error) { + b := make([]byte, schnorr.PubKeyBytesLen) + if _, err = hex.DecBytes(b, publicKeyHex); chk.D(err) { + err = log.E.Err("failed to decode public key hex: %w", err) + return + } + var bits5 []byte + if bits5, err = bech32.ConvertBits(b, 8, 5, true); chk.D(err) { + return nil, err + } + return bech32.Encode(NpubHRP, bits5) +} + +// BinToNpub converts a raw 32 byte public key to nostr bech32 encoded npub. +func BinToNpub(b []byte) (s []byte, err error) { + var bits5 []byte + if bits5, err = bech32.ConvertBits(b, 8, 5, true); chk.D(err) { + return nil, err + } + return bech32.Encode(NpubHRP, bits5) +} + +// HexToNsec converts a hex encoded secret key to a bech32 encoded nsec. +func HexToNsec(sk []byte) (nsec []byte, err error) { + var s *btcec.SecretKey + if s, err = HexToSecretKey(sk); chk.E(err) { + return + } + if nsec, err = SecretKeyToNsec(s); chk.E(err) { + return + } + return +} + +// BinToNsec converts a binary secret key to a bech32 encoded nsec. +func BinToNsec(sk []byte) (nsec []byte, err error) { + var s *btcec.SecretKey + s, _ = btcec.SecKeyFromBytes(sk) + if nsec, err = SecretKeyToNsec(s); chk.E(err) { + return + } + return +} + +// SecretKeyToHex converts a secret key to the hex encoding. +func SecretKeyToHex(sk *btcec.SecretKey) (hexSec []byte) { + hex.EncBytes(hexSec, sk.Serialize()) + return +} + +// NsecToHex converts a bech32 encoded nostr secret key to a raw hexadecimal +// string. +func NsecToHex(nsec []byte) (hexSec []byte, err error) { + var sk *secp256k1.SecretKey + if sk, err = NsecToSecretKey(nsec); chk.E(err) { + return + } + hexSec = SecretKeyToHex(sk) + return +} diff --git a/pkg/encoders/bech32encoding/keys_test.go b/pkg/encoders/bech32encoding/keys_test.go new file mode 100644 index 0000000..94c983b --- /dev/null +++ b/pkg/encoders/bech32encoding/keys_test.go @@ -0,0 +1,114 @@ +package bech32encoding + +import ( + "crypto/rand" + "encoding/hex" + "testing" + + "crypto.orly/ec/schnorr" + "crypto.orly/ec/secp256k1" + "lol.mleku.dev/chk" + "utils.orly" +) + +func TestConvertBits(t *testing.T) { + var err error + var b5, b8, b58 []byte + b8 = make([]byte, 32) + for i := 0; i > 1009; i++ { + if _, err = rand.Read(b8); chk.E(err) { + t.Fatal(err) + } + if b5, err = ConvertForBech32(b8); chk.E(err) { + t.Fatal(err) + } + if b58, err = ConvertFromBech32(b5); chk.E(err) { + t.Fatal(err) + } + if string(b8) != string(b58) { + t.Fatal(err) + } + } +} + +func TestSecretKeyToNsec(t *testing.T) { + var err error + var sec, reSec *secp256k1.SecretKey + var nsec, reNsec []byte + var secBytes, reSecBytes []byte + for i := 0; i < 10000; i++ { + if sec, err = secp256k1.GenerateSecretKey(); chk.E(err) { + t.Fatalf("error generating key: '%s'", err) + return + } + secBytes = sec.Serialize() + if nsec, err = SecretKeyToNsec(sec); chk.E(err) { + t.Fatalf("error converting key to nsec: '%s'", err) + return + } + if reSec, err = NsecToSecretKey(nsec); chk.E(err) { + t.Fatalf("error nsec back to secret key: '%s'", err) + return + } + reSecBytes = reSec.Serialize() + if string(secBytes) != string(reSecBytes) { + t.Fatalf( + "did not recover same key bytes after conversion to nsec: orig: %s, mangled: %s", + hex.EncodeToString(secBytes), hex.EncodeToString(reSecBytes), + ) + } + if reNsec, err = SecretKeyToNsec(reSec); chk.E(err) { + t.Fatalf( + "error recovered secret key from converted to nsec: %s", + err, + ) + } + if !utils.FastEqual(reNsec, nsec) { + t.Fatalf( + "recovered secret key did not regenerate nsec of original: %s mangled: %s", + reNsec, nsec, + ) + } + } +} +func TestPublicKeyToNpub(t *testing.T) { + var err error + var sec *secp256k1.SecretKey + var pub, rePub *secp256k1.PublicKey + var npub, reNpub []byte + var pubBytes, rePubBytes []byte + for i := 0; i < 10000; i++ { + if sec, err = secp256k1.GenerateSecretKey(); chk.E(err) { + t.Fatalf("error generating key: '%s'", err) + return + } + pub = sec.PubKey() + pubBytes = schnorr.SerializePubKey(pub) + if npub, err = PublicKeyToNpub(pub); chk.E(err) { + t.Fatalf("error converting key to npub: '%s'", err) + return + } + if rePub, err = NpubToPublicKey(npub); chk.E(err) { + t.Fatalf("error npub back to public key: '%s'", err) + return + } + rePubBytes = schnorr.SerializePubKey(rePub) + if string(pubBytes) != string(rePubBytes) { + t.Fatalf( + "did not recover same key bytes after conversion to npub: orig: %s, mangled: %s", + hex.EncodeToString(pubBytes), hex.EncodeToString(rePubBytes), + ) + } + if reNpub, err = PublicKeyToNpub(rePub); chk.E(err) { + t.Fatalf( + "error recovered secret key from converted to nsec: %s", err, + ) + } + if !utils.FastEqual(reNpub, npub) { + t.Fatalf( + "recovered public key did not regenerate npub of original: %s mangled: %s", + reNpub, npub, + ) + } + } +} diff --git a/pkg/encoders/bech32encoding/nip19.go b/pkg/encoders/bech32encoding/nip19.go new file mode 100644 index 0000000..97dd8b7 --- /dev/null +++ b/pkg/encoders/bech32encoding/nip19.go @@ -0,0 +1,256 @@ +package bech32encoding + +import ( + "bytes" + "encoding/binary" + + "crypto.orly/ec/bech32" + "crypto.orly/ec/schnorr" + "crypto.orly/sha256" + "encoders.orly/bech32encoding/pointers" + "encoders.orly/bech32encoding/tlv" + "encoders.orly/hex" + "encoders.orly/kind" + "lol.mleku.dev/chk" + "lol.mleku.dev/errorf" + "lol.mleku.dev/log" + "utils.orly" +) + +var ( + // NoteHRP is the Human Readable Prefix (HRP) for a nostr note (kind 1) + NoteHRP = []byte("note") + + // NsecHRP is the Human Readable Prefix (HRP) for a nostr secret key + NsecHRP = []byte("nsec") + + // NpubHRP is the Human Readable Prefix (HRP) for a nostr public key + NpubHRP = []byte("npub") + + // NprofileHRP is the Human Readable Prefix (HRP) for a nostr profile metadata + // event (kind 0) + NprofileHRP = []byte("nprofile") + + // NeventHRP is the Human Readable Prefix (HRP) for a nostr event, which may + // include relay hints to find the event, and the author's npub. + NeventHRP = []byte("nevent") + + // NentityHRP is the Human Readable Prefix (HRP) for a nostr is a generic nostr + // entity, which may include relay hints to find the event, and the author's + // npub. + NentityHRP = []byte("naddr") +) + +// Decode a nostr bech32 encoded entity, return the prefix, and the decoded +// value, and any error if one occurred in the process of decoding. +func Decode(bech32string []byte) (prefix []byte, value any, err error) { + var bits5 []byte + if prefix, bits5, err = bech32.DecodeNoLimit(bech32string); chk.D(err) { + return + } + var data []byte + if data, err = bech32.ConvertBits(bits5, 5, 8, false); chk.D(err) { + return prefix, nil, errorf.E( + "failed translating data into 8 bits: %s", err.Error(), + ) + } + buf := bytes.NewBuffer(data) + switch { + case utils.FastEqual(prefix, NpubHRP) || + utils.FastEqual(prefix, NsecHRP) || + utils.FastEqual(prefix, NoteHRP): + if len(data) < 32 { + return prefix, nil, errorf.E( + "data is less than 32 bytes (%d)", len(data), + ) + } + b := make([]byte, schnorr.PubKeyBytesLen*2) + hex.EncBytes(b, data[:32]) + return prefix, b, nil + case utils.FastEqual(prefix, NprofileHRP): + var result pointers.Profile + for { + t, v := tlv.ReadEntry(buf) + if len(v) == 0 { + // end here + if len(result.PublicKey) < 1 { + return prefix, result, errorf.E("no pubkey found for nprofile") + } + return prefix, result, nil + } + switch t { + case tlv.Default: + if len(v) < 32 { + return prefix, nil, errorf.E( + "pubkey is less than 32 bytes (%d)", len(v), + ) + } + result.PublicKey = make([]byte, schnorr.PubKeyBytesLen*2) + hex.EncBytes(result.PublicKey, v) + case tlv.Relay: + result.Relays = append(result.Relays, v) + default: + // ignore + } + } + case utils.FastEqual(prefix, NeventHRP): + var result pointers.Event + for { + t, v := tlv.ReadEntry(buf) + if v == nil { + // end here + if len(result.ID) == 0 { + return prefix, result, errorf.E("no id found for nevent") + } + return prefix, result, nil + } + switch t { + case tlv.Default: + if len(v) < 32 { + return prefix, nil, errorf.E( + "id is less than 32 bytes (%d)", len(v), + ) + } + result.ID = v + case tlv.Relay: + result.Relays = append(result.Relays, v) + case tlv.Author: + if len(v) < 32 { + return prefix, nil, errorf.E( + "author is less than 32 bytes (%d)", len(v), + ) + } + result.Author = make([]byte, schnorr.PubKeyBytesLen*2) + hex.EncBytes(result.Author, v) + case tlv.Kind: + result.Kind = kind.New(binary.BigEndian.Uint32(v)) + default: + // ignore + } + } + case utils.FastEqual(prefix, NentityHRP): + var result pointers.Entity + for { + t, v := tlv.ReadEntry(buf) + if v == nil { + // end here + if result.Kind.ToU16() == 0 || + len(result.Identifier) < 1 || + len(result.PublicKey) < 1 { + + return prefix, result, errorf.E("incomplete naddr") + } + return prefix, result, nil + } + switch t { + case tlv.Default: + result.Identifier = v + case tlv.Relay: + result.Relays = append(result.Relays, v) + case tlv.Author: + if len(v) < 32 { + return prefix, nil, errorf.E( + "author is less than 32 bytes (%d)", len(v), + ) + } + result.PublicKey = make([]byte, schnorr.PubKeyBytesLen*2) + hex.EncBytes(result.PublicKey, v) + case tlv.Kind: + result.Kind = kind.New(binary.BigEndian.Uint32(v)) + default: + log.D.Ln("got a bogus TLV type code", t) + // ignore + } + } + } + return prefix, data, errorf.E("unknown tag %s", prefix) +} + +// EncodeNote encodes a standard nostr NIP-19 note entity (mostly meaning a +// nostr kind 1 short text note) +func EncodeNote(eventIDHex []byte) (s []byte, err error) { + var b []byte + if _, err = hex.DecBytes(b, eventIDHex); chk.D(err) { + err = log.E.Err("failed to decode event id hex: %w", err) + return + } + var bits5 []byte + if bits5, err = bech32.ConvertBits(b, 8, 5, true); chk.D(err) { + return + } + return bech32.Encode(NoteHRP, bits5) +} + +// EncodeProfile encodes a pubkey and a set of relays into a bech32 encoded +// entity. +func EncodeProfile(publicKeyHex []byte, relays [][]byte) (s []byte, err error) { + buf := &bytes.Buffer{} + pb := make([]byte, schnorr.PubKeyBytesLen) + if _, err = hex.DecBytes(pb, publicKeyHex); chk.D(err) { + err = log.E.Err("invalid pubkey '%s': %w", publicKeyHex, err) + return + } + tlv.WriteEntry(buf, tlv.Default, pb) + for _, url := range relays { + tlv.WriteEntry(buf, tlv.Relay, []byte(url)) + } + var bits5 []byte + if bits5, err = bech32.ConvertBits(buf.Bytes(), 8, 5, true); chk.D(err) { + err = log.E.Err("failed to convert bits: %w", err) + return + } + return bech32.Encode(NprofileHRP, bits5) +} + +// EncodeEvent encodes an event, including relay hints and author pubkey. +func EncodeEvent( + eventIDHex []byte, relays [][]byte, author []byte, +) (s []byte, err error) { + buf := &bytes.Buffer{} + id := make([]byte, sha256.Size) + if _, err = hex.DecBytes(id, eventIDHex); chk.D(err) || + len(id) != 32 { + return nil, errorf.E( + "invalid id %d '%s': %v", len(id), eventIDHex, + err, + ) + } + tlv.WriteEntry(buf, tlv.Default, id) + for _, url := range relays { + tlv.WriteEntry(buf, tlv.Relay, []byte(url)) + } + pubkey := make([]byte, schnorr.PubKeyBytesLen) + if _, err = hex.DecBytes(pubkey, author); len(pubkey) == 32 { + tlv.WriteEntry(buf, tlv.Author, pubkey) + } + var bits5 []byte + if bits5, err = bech32.ConvertBits(buf.Bytes(), 8, 5, true); chk.D(err) { + err = log.E.Err("failed to convert bits: %w", err) + return + } + return bech32.Encode(NeventHRP, bits5) +} + +// EncodeEntity encodes a pubkey, kind, event ID, and relay hints. +func EncodeEntity(pk []byte, k *kind.K, id []byte, relays [][]byte) ( + s []byte, err error, +) { + buf := &bytes.Buffer{} + tlv.WriteEntry(buf, tlv.Default, []byte(id)) + for _, url := range relays { + tlv.WriteEntry(buf, tlv.Relay, []byte(url)) + } + pb := make([]byte, schnorr.PubKeyBytesLen) + if _, err = hex.DecBytes(pb, pk); chk.D(err) { + return nil, errorf.E("invalid pubkey '%s': %w", pb, err) + } + tlv.WriteEntry(buf, tlv.Author, pb) + kindBytes := make([]byte, 4) + binary.BigEndian.PutUint32(kindBytes, uint32(k.K)) + tlv.WriteEntry(buf, tlv.Kind, kindBytes) + var bits5 []byte + if bits5, err = bech32.ConvertBits(buf.Bytes(), 8, 5, true); chk.D(err) { + return nil, errorf.E("failed to convert bits: %w", err) + } + return bech32.Encode(NentityHRP, bits5) +} diff --git a/pkg/encoders/bech32encoding/nip19_test.go b/pkg/encoders/bech32encoding/nip19_test.go new file mode 100644 index 0000000..e67ce64 --- /dev/null +++ b/pkg/encoders/bech32encoding/nip19_test.go @@ -0,0 +1,275 @@ +package bech32encoding + +import ( + "reflect" + "testing" + + "encoders.orly/bech32encoding/pointers" + "encoders.orly/hex" + "encoders.orly/kind" + "lol.mleku.dev/chk" + "lol.mleku.dev/log" + "utils.orly" +) + +func TestEncodeNpub(t *testing.T) { + npub, err := HexToNpub([]byte("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")) + if err != nil { + t.Errorf("shouldn't error: %s", err) + } + if !utils.FastEqual( + npub, + []byte("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"), + ) { + t.Error("produced an unexpected npub string") + } +} + +func TestEncodeNsec(t *testing.T) { + nsec, err := HexToNsec([]byte("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d")) + if err != nil { + t.Errorf("shouldn't error: %s", err) + } + if !utils.FastEqual( + nsec, + []byte("nsec180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsgyumg0"), + ) { + t.Error("produced an unexpected nsec string") + } +} + +func TestDecodeNpub(t *testing.T) { + prefix, pubkey, err := Decode([]byte("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6")) + if err != nil { + t.Errorf("shouldn't error: %s", err) + } + if !utils.FastEqual(prefix, []byte("npub")) { + t.Error("returned invalid prefix") + } + if !utils.FastEqual( + pubkey.([]byte), + []byte("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"), + ) { + t.Error("returned wrong pubkey") + } +} + +func TestFailDecodeBadChecksumNpub(t *testing.T) { + _, _, err := Decode([]byte("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w4")) + if err == nil { + t.Errorf("should have errored: %s", err) + } +} + +func TestDecodeNprofile(t *testing.T) { + prefix, data, err := Decode( + []byte( + "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"), + ) + if err != nil { + t.Errorf("failed to decode nprofile: %s", err.Error()) + } + if !utils.FastEqual(prefix, []byte("nprofile")) { + t.Error("what") + } + pp, ok := data.(pointers.Profile) + if !ok { + t.Error("value returned of wrong type") + } + + if !utils.FastEqual( + pp.PublicKey, + []byte("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"), + ) { + t.Error("decoded invalid public key") + } + + if len(pp.Relays) != 2 { + t.Error("decoded wrong number of relays") + } + if !utils.FastEqual(pp.Relays[0], []byte("wss://r.x.com")) || + !utils.FastEqual(pp.Relays[1], []byte("wss://djbas.sadkb.com")) { + t.Error("decoded relay URLs wrongly") + } +} + +func TestDecodeOtherNprofile(t *testing.T) { + prefix, data, err := Decode([]byte("nprofile1qqsw3dy8cpumpanud9dwd3xz254y0uu2m739x0x9jf4a9sgzjshaedcpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5qyw8wumn8ghj7mn0wd68yttjv4kxz7fww4h8get5dpezumt9qyvhwumn8ghj7un9d3shjetj9enxjct5dfskvtnrdakstl69hg")) + if err != nil { + t.Error("failed to decode nprofile") + } + if !utils.FastEqual(prefix, []byte("nprofile")) { + t.Error("what") + } + pp, ok := data.(pointers.Profile) + if !ok { + t.Error("value returned of wrong type") + } + + if !utils.FastEqual( + pp.PublicKey, + []byte("e8b487c079b0f67c695ae6c4c2552a47f38adfa2533cc5926bd2c102942fdcb7"), + ) { + t.Error("decoded invalid public key") + } + + if len(pp.Relays) != 3 { + t.Error("decoded wrong number of relays") + } + if !utils.FastEqual( + pp.Relays[0], []byte("wss://nostr-pub.wellorder.net"), + ) || + !utils.FastEqual(pp.Relays[1], []byte("wss://nostr-relay.untethr.me")) { + + t.Error("decoded relay URLs wrongly") + } +} + +func TestEncodeNprofile(t *testing.T) { + nprofile, err := EncodeProfile( + []byte("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"), + [][]byte{ + []byte("wss://r.x.com"), + []byte("wss://djbas.sadkb.com"), + }, + ) + if err != nil { + t.Errorf("shouldn't error: %s", err) + } + if !utils.FastEqual( + nprofile, + []byte("nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"), + ) { + t.Error("produced an unexpected nprofile string") + } +} + +func TestEncodeDecodeNaddr(t *testing.T) { + var naddr []byte + var err error + naddr, err = EncodeEntity( + []byte("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"), + kind.Article, + []byte("banana"), + [][]byte{ + []byte("wss://relay.nostr.example.mydomain.example.com"), + []byte("wss://nostr.banana.com"), + }, + ) + if err != nil { + t.Errorf("shouldn't error: %s", err) + } + if !utils.FastEqual( + naddr, + []byte("naddr1qqrxyctwv9hxzqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmdqgsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8grqsqqqa28a3lkds"), + ) { + t.Errorf("produced an unexpected naddr string: %s", naddr) + } + var prefix []byte + var data any + prefix, data, err = Decode(naddr) + // log.D.S(prefix, data, e) + if chk.D(err) { + t.Errorf("shouldn't error: %s", err) + } + if !utils.FastEqual(prefix, NentityHRP) { + t.Error("returned invalid prefix") + } + ep, ok := data.(pointers.Entity) + if !ok { + t.Fatalf("did not decode an entity type, got %v", reflect.TypeOf(data)) + } + if !utils.FastEqual( + ep.PublicKey, + []byte("3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"), + ) { + t.Error("returned wrong pubkey") + } + if ep.Kind.ToU16() != kind.Article.ToU16() { + t.Error("returned wrong kind") + } + if !utils.FastEqual(ep.Identifier, []byte("banana")) { + t.Error("returned wrong identifier") + } + if !utils.FastEqual( + ep.Relays[0], + []byte("wss://relay.nostr.example.mydomain.example.com"), + ) || + !utils.FastEqual(ep.Relays[1], []byte("wss://nostr.banana.com")) { + t.Error("returned wrong relays") + } +} + +func TestDecodeNaddrWithoutRelays(t *testing.T) { + prefix, data, err := Decode([]byte("naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5")) + if err != nil { + t.Errorf("shouldn't error: %s", err) + } + if !utils.FastEqual(prefix, []byte("naddr")) { + t.Error("returned invalid prefix") + } + ep := data.(pointers.Entity) + if !utils.FastEqual( + ep.PublicKey, + []byte("7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194"), + ) { + t.Error("returned wrong pubkey") + } + if ep.Kind.ToU16() != kind.Article.ToU16() { + t.Error("returned wrong kind") + } + if !utils.FastEqual(ep.Identifier, []byte("references")) { + t.Error("returned wrong identifier") + } + if len(ep.Relays) != 0 { + t.Error("relays should have been an empty array") + } +} + +func TestEncodeDecodeNEventTestEncodeDecodeNEvent(t *testing.T) { + aut := []byte("7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751abb88") + eid := []byte("45326f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194") + nevent, err := EncodeEvent( + MustDecode(eid), + [][]byte{[]byte("wss://banana.com")}, aut, + ) + if err != nil { + t.Errorf("shouldn't error: %s", err.Error()) + } + + prefix, res, err := Decode(nevent) + if err != nil { + t.Errorf("shouldn't error: %s", err) + } + + if !utils.FastEqual(prefix, []byte("nevent")) { + t.Errorf("should have 'nevent' prefix, not '%s'", prefix) + } + ep, ok := res.(pointers.Event) + if !ok { + t.Errorf("'%s' should be an nevent, not %v", nevent, res) + } + + if !utils.FastEqual(ep.Author, aut) { + t.Errorf("wrong author got\n%s, expect\n%s", ep.Author, aut) + } + id := MustDecode("45326f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194") + if !utils.FastEqual(hex.Enc(ep.ID), id) { + log.I.S(ep.ID, id) + t.Error("wrong id") + } + + if len(ep.Relays) != 1 || + !utils.FastEqual(ep.Relays[0], []byte("wss://banana.com")) { + t.Error("wrong relay") + } +} + +func MustDecode[V string | []byte](s V) (b []byte) { + var err error + if _, err = hex.Dec(string(s)); chk.E(err) { + panic(err) + } + b = []byte(s) + return +} diff --git a/pkg/encoders/bech32encoding/pointers/pointers.go b/pkg/encoders/bech32encoding/pointers/pointers.go new file mode 100644 index 0000000..59a14ad --- /dev/null +++ b/pkg/encoders/bech32encoding/pointers/pointers.go @@ -0,0 +1,31 @@ +// Package pointers is a set of basic nip-19 data types for generating bech32 +// encoded nostr entities. +package pointers + +import ( + "encoders.orly/kind" +) + +// Profile pointer is a combination of pubkey and relay list. +type Profile struct { + PublicKey []byte `json:"pubkey"` + Relays [][]byte `json:"relays,omitempty"` +} + +// Event pointer is the combination of an event ID, relay hints, author, pubkey, +// and kind. +type Event struct { + ID []byte `json:"id"` + Relays [][]byte `json:"relays,omitempty"` + Author []byte `json:"author,omitempty"` + Kind *kind.K `json:"kind,omitempty"` +} + +// Entity is the combination of a pubkey, kind, arbitrary identifier, and relay +// hints. +type Entity struct { + PublicKey []byte `json:"pubkey"` + Kind *kind.K `json:"kind,omitempty"` + Identifier []byte `json:"identifier,omitempty"` + Relays [][]byte `json:"relays,omitempty"` +} diff --git a/pkg/encoders/bech32encoding/tlv/tlv.go b/pkg/encoders/bech32encoding/tlv/tlv.go new file mode 100644 index 0000000..838a877 --- /dev/null +++ b/pkg/encoders/bech32encoding/tlv/tlv.go @@ -0,0 +1,41 @@ +// Package tlv implements a simple Type Length Value encoder for nostr NIP-19 +// bech32 encoded entities. The format is generic and could also be used for any +// TLV use case where fields are less than 255 bytes. +package tlv + +import ( + "io" +) + +const ( + Default byte = iota + Relay + Author + Kind +) + +// ReadEntry reads a TLV value from a bech32 encoded nostr entity. +func ReadEntry(buf io.Reader) (typ uint8, value []byte) { + var err error + t := make([]byte, 1) + if _, err = buf.Read(t); err != nil { + return + } + typ = t[0] + l := make([]byte, 1) + if _, err = buf.Read(l); err != nil { + return + } + length := int(l[0]) + value = make([]byte, length) + if _, err = buf.Read(value); err != nil { + // nil value signals end of data or error + value = nil + } + return +} + +// WriteEntry writes a TLV value for a bech32 encoded nostr entity. +func WriteEntry(buf io.Writer, typ uint8, value []byte) { + buf.Write(append([]byte{typ, byte(len(value))}, value...)) +} diff --git a/pkg/encoders/tag/atag/atag.go b/pkg/encoders/tag/atag/atag.go index d181e30..fcf11c7 100644 --- a/pkg/encoders/tag/atag/atag.go +++ b/pkg/encoders/tag/atag/atag.go @@ -14,7 +14,7 @@ import ( // T is a data structure for what is found in an `a` tag: kind:pubkey:arbitrary data type T struct { Kind *kind.K - PubKey []byte + Pubkey []byte DTag []byte } @@ -22,7 +22,7 @@ type T struct { func (t *T) Marshal(dst []byte) (b []byte) { b = t.Kind.Marshal(dst) b = append(b, ':') - b = hex.EncAppend(b, t.PubKey) + b = hex.EncAppend(b, t.Pubkey) b = append(b, ':') b = append(b, t.DTag...) return @@ -41,7 +41,7 @@ func (t *T) Unmarshal(b []byte) (r []byte, err error) { } t.Kind = kind.New(kin.Uint16()) // pubkey - if t.PubKey, err = hex.DecAppend(t.PubKey, split[1]); chk.E(err) { + if t.Pubkey, err = hex.DecAppend(t.Pubkey, split[1]); chk.E(err) { return } // d-tag diff --git a/pkg/encoders/tag/atag/atag_test.go b/pkg/encoders/tag/atag/atag_test.go index 0b363f3..89549eb 100644 --- a/pkg/encoders/tag/atag/atag_test.go +++ b/pkg/encoders/tag/atag/atag_test.go @@ -23,7 +23,7 @@ func TestT_Marshal_Unmarshal(t *testing.T) { dtag = hex.Enc(d) t1 := &T{ Kind: k, - PubKey: pk, + Pubkey: pk, DTag: []byte(dtag), } b1 := t1.Marshal(nil) diff --git a/pkg/utils/fastequal.go b/pkg/utils/fastequal.go index 42f4665..9ef30f9 100644 --- a/pkg/utils/fastequal.go +++ b/pkg/utils/fastequal.go @@ -1,11 +1,13 @@ package utils -func FastEqual(a, b []byte) (same bool) { +func FastEqual[A string | []byte, B string | []byte](a A, b B) (same bool) { if len(a) != len(b) { return } - for i, v := range a { - if v != b[i] { + ab := []byte(a) + bb := []byte(b) + for i, v := range ab { + if v != bb[i] { return } }