faster signature verification by serializing directly into the sha with less allocations.

This commit is contained in:
fiatjaf
2026-04-23 20:31:57 -03:00
parent 696f377109
commit b899ef8865
6 changed files with 224 additions and 70 deletions
+213 -13
View File
@@ -2,7 +2,9 @@ package nostr
import (
"crypto/sha256"
"hash"
"strconv"
"unsafe"
"github.com/mailru/easyjson"
"github.com/templexxx/xhex"
@@ -26,10 +28,17 @@ func (evt Event) String() string {
// GetID serializes and returns the event ID as a string.
func (evt Event) GetID() ID {
return sha256.Sum256(evt.Serialize())
var id ID
evt.serializedHash(&id)
return id
}
// CheckID checks if the implied ID matches the given ID more efficiently.
// SetID calculates and sets the id to the event in a single operation.
func (evt *Event) SetID() {
evt.serializedHash(&evt.ID)
}
// CheckID checks if the implied ID matches the currently assigned ID.
func (evt Event) CheckID() bool {
return evt.GetID() == evt.ID
}
@@ -38,17 +47,56 @@ func (evt Event) CheckID() bool {
func (evt Event) Serialize() []byte {
// the serialization process is just putting everything into a JSON array
// so the order is kept. See NIP-01
dst := make([]byte, 4+64, 100+len(evt.Content)+len(evt.Tags)*80)
dst := make([]byte, 0, 100+len(evt.Content)+len(evt.Tags)*80)
return evt.appendSerialized(dst)
}
// the header portion is easy to serialize
// [0,"pubkey",created_at,kind,[
copy(dst, `[0,"`)
xhex.Encode(dst[4:4+64], evt.PubKey[:]) // there will always be such capacity
var escTable [256]bool
// pre-built escape sequences; index by the offending byte.
var escSeq [256][2]byte
// pre-built []byte slices for hash.Write calls (no per-call allocation).
var escSlice [256][]byte
var (
jsonQuote = []byte{'"'}
serializedStart = []byte(`[0,"`)
serializedPubkeyEnd = []byte(`",`)
serializedTagsEnd = []byte("],")
serializedTagStart = []byte{'['}
serializedTagEnd = []byte{']'}
serializedComma = []byte{','}
serializedEnd = []byte{']'}
)
func init() {
for _, b := range []byte{'"', '\\', '\n', '\r', '\t'} {
escTable[b] = true
}
escSeq['"'] = [2]byte{'\\', '"'}
escSeq['\\'] = [2]byte{'\\', '\\'}
escSeq['\n'] = [2]byte{'\\', 'n'}
escSeq['\r'] = [2]byte{'\\', 'r'}
escSeq['\t'] = [2]byte{'\\', 't'}
for b, seq := range escSeq {
if escTable[b] {
escSlice[b] = seq[:]
}
}
}
func (evt Event) appendSerialized(dst []byte) []byte {
start := len(dst)
dst = append(dst, `[0,"`...)
dst = append(dst, make([]byte, 64)...)
xhex.Encode(dst[start+4:start+4+64], evt.PubKey[:])
dst = append(dst, `",`...)
dst = append(dst, strconv.FormatInt(int64(evt.CreatedAt), 10)...)
dst = append(dst, `,`...)
dst = append(dst, strconv.FormatUint(uint64(evt.Kind), 10)...)
dst = append(dst, `,`...)
dst = strconv.AppendInt(dst, int64(evt.CreatedAt), 10)
dst = append(dst, ',')
dst = strconv.AppendUint(dst, uint64(evt.Kind), 10)
dst = append(dst, ',')
// tags
dst = append(dst, '[')
@@ -62,15 +110,167 @@ func (evt Event) Serialize() []byte {
if i > 0 {
dst = append(dst, ',')
}
dst = escapeString(dst, s)
dst = appendJSONString(dst, s)
}
dst = append(dst, ']')
}
dst = append(dst, "],"...)
// content needs to be escaped in general as it is user generated.
dst = escapeString(dst, evt.Content)
dst = appendJSONString(dst, evt.Content)
dst = append(dst, ']')
return dst
}
func (evt Event) serializedHash(dst *ID) {
h := sha256.New()
h.Write(serializedStart)
var pubkeyHex [64]byte
xhex.Encode(pubkeyHex[:], evt.PubKey[:])
h.Write(pubkeyHex[:])
h.Write(serializedPubkeyEnd)
var numBuf [20]byte
b := strconv.AppendInt(numBuf[:0], int64(evt.CreatedAt), 10)
h.Write(b)
h.Write(serializedComma)
b = strconv.AppendUint(numBuf[:0], uint64(evt.Kind), 10)
h.Write(b)
h.Write(serializedComma)
h.Write(serializedTagStart)
for i, tag := range evt.Tags {
if i > 0 {
h.Write(serializedComma)
}
h.Write(serializedTagStart)
for j, s := range tag {
if j > 0 {
h.Write(serializedComma)
}
writeJSONString(h, s)
}
h.Write(serializedTagEnd)
}
h.Write(serializedTagsEnd)
writeJSONString(h, evt.Content)
h.Write(serializedEnd)
h.Sum((*dst)[:0])
}
// ── SWAR helper ──────────────────────────────────────────────────────────────
// hasSpecial returns non-zero if any byte in w is one of: \t 0x09, \n 0x0A,
// " 0x22, \ 0x5C. Uses the classic "hasvalue" bit-trick — no branches, no
// memory, pure ALU. Works regardless of endianness because we only care
// whether a match exists, not where.
//
//go:nosplit
func hasSpecial(w uint64) bool {
match := func(w, v uint64) uint64 {
x := w ^ (0x0101010101010101 * v)
return (x - 0x0101010101010101) & ^x & 0x8080808080808080
}
return match(w, 0x09)|match(w, 0x0A)|match(w, 0x0D)|match(w, 0x22)|match(w, 0x5C) != 0
}
func appendJSONString(dst []byte, s string) []byte {
dst = append(dst, '"')
n := len(s)
if n == 0 {
return append(dst, '"')
}
base := uintptr(unsafe.Pointer(unsafe.StringData(s)))
start, i := 0, 0
// consume 8 bytes at a time;
// if the whole word is clean, advance without touching dst at all;
// but when a word is dirty, fall back to the byte loop only for that 8-byte window
for i+8 <= n {
w := *(*uint64)(unsafe.Pointer(base + uintptr(i)))
if hasSpecial(w) {
for end := i + 8; i < end; i++ {
if escTable[s[i]] {
// append everything since the start or the last time we did this up to here
dst = append(dst, s[start:i]...)
// append this special sequence
seq := escSeq[s[i]]
dst = append(dst, seq[0], seq[1])
// set this as a checkpoint
start = i + 1
}
}
} else {
i += 8
}
}
// scalar tail for the remaining <8 bytes (same logic used for the hasSpecial branch above)
for ; i < n; i++ {
if escTable[s[i]] {
dst = append(dst, s[start:i]...)
seq := escSeq[s[i]]
dst = append(dst, seq[0], seq[1])
start = i + 1
}
}
// add the remaining chunk (in a string without any specials this will add everything at once)
dst = append(dst, s[start:]...)
return append(dst, '"')
}
func writeJSONString(h hash.Hash, s string) {
h.Write(jsonQuote)
n := len(s)
if n == 0 {
h.Write(jsonQuote)
return
}
base := uintptr(unsafe.Pointer(unsafe.StringData(s)))
start, i := 0, 0
for i+8 <= n {
w := *(*uint64)(unsafe.Pointer(base + uintptr(i)))
// apply same logic as of appendJSONString()
if hasSpecial(w) {
for end := i + 8; i < end; i++ {
if escTable[s[i]] {
if i > start {
h.Write(unsafe.Slice(unsafe.StringData(s[start:i]), i-start))
}
h.Write(escSlice[s[i]])
start = i + 1
}
}
} else {
i += 8
}
}
for ; i < n; i++ {
if escTable[s[i]] {
if i > start {
h.Write(unsafe.Slice(unsafe.StringData(s[start:i]), i-start))
}
h.Write(escSlice[s[i]])
start = i + 1
}
}
if start < n {
h.Write(unsafe.Slice(unsafe.StringData(s[start:]), len(s)-start))
}
h.Write(jsonQuote)
}
+1 -1
View File
@@ -40,7 +40,7 @@ require (
)
require (
fiatjaf.com/lib v0.3.6
fiatjaf.com/lib v0.3.7
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/go-git/go-git/v5 v5.16.3
github.com/pemistahl/lingua-go v1.4.0
+2 -2
View File
@@ -1,5 +1,5 @@
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc=
github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw=
github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc=
-40
View File
@@ -92,46 +92,6 @@ func similarPublicKey(as, bs []PubKey) bool {
return true
}
// Escaping strings for JSON encoding according to RFC8259.
// Also encloses result in quotation marks "".
func escapeString(dst []byte, s string) []byte {
dst = append(dst, '"')
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '"':
// quotation mark
dst = append(dst, []byte{'\\', '"'}...)
case c == '\\':
// reverse solidus
dst = append(dst, []byte{'\\', '\\'}...)
case c >= 0x20:
// default, rest below are control chars
dst = append(dst, c)
case c == 0x08:
dst = append(dst, []byte{'\\', 'b'}...)
case c < 0x09:
dst = append(dst, []byte{'\\', 'u', '0', '0', '0', '0' + c}...)
case c == 0x09:
dst = append(dst, []byte{'\\', 't'}...)
case c == 0x0a:
dst = append(dst, []byte{'\\', 'n'}...)
case c == 0x0c:
dst = append(dst, []byte{'\\', 'f'}...)
case c == 0x0d:
dst = append(dst, []byte{'\\', 'r'}...)
case c < 0x10:
dst = append(dst, []byte{'\\', 'u', '0', '0', '0', 0x57 + c}...)
case c < 0x1a:
dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x20 + c}...)
case c < 0x20:
dst = append(dst, []byte{'\\', 'u', '0', '0', '1', 0x47 + c}...)
}
}
dst = append(dst, '"')
return dst
}
func subIdToSerial(subId string) int64 {
n := strings.Index(subId, ":")
if n < 0 || n > len(subId) {
+4 -7
View File
@@ -3,8 +3,6 @@
package nostr
import (
"crypto/sha256"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
@@ -34,8 +32,8 @@ func (evt Event) VerifySignature() bool {
sig := schnorr.NewSignature(&r, &s)
// check signature
hash := sha256.Sum256(evt.Serialize())
return sig.Verify(hash[:], pubkey)
evt.SetID()
return sig.Verify(evt.ID[:], pubkey)
}
// Sign signs an event with a given privateKey.
@@ -52,13 +50,12 @@ func (evt *Event) Sign(secretKey [32]byte) error {
pkBytes := pk.SerializeCompressed()[1:]
evt.PubKey = PubKey(pkBytes)
h := sha256.Sum256(evt.Serialize())
sig, err := schnorr.Sign(sk, h[:], schnorr.FastSign())
evt.SetID()
sig, err := schnorr.Sign(sk, evt.ID[:], schnorr.FastSign())
if err != nil {
return err
}
evt.ID = h
sigb := sig.Serialize()
evt.Sig = [64]byte(sigb)
+4 -7
View File
@@ -25,7 +25,6 @@ import "C"
import (
"crypto/rand"
"crypto/sha256"
"errors"
"unsafe"
@@ -33,14 +32,14 @@ import (
)
func (evt Event) VerifySignature() bool {
msg := sha256.Sum256(evt.Serialize())
evt.SetID()
var xonly C.secp256k1_xonly_pubkey
if C.secp256k1_xonly_pubkey_parse(globalSecp256k1Context, &xonly, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0]))) != 1 {
return false
}
res := C.secp256k1_schnorrsig_verify(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&msg[0])), 32, &xonly)
res := C.secp256k1_schnorrsig_verify(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&evt.ID[0])), 32, &xonly)
return res == 1
}
@@ -59,16 +58,14 @@ func (evt *Event) Sign(secretKey [32]byte, signOpts ...schnorr.SignOption) error
C.secp256k1_keypair_xonly_pub(globalSecp256k1Context, &xonly, nil, &keypair)
C.secp256k1_xonly_pubkey_serialize(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.PubKey[0])), &xonly)
h := sha256.Sum256(evt.Serialize())
evt.SetID()
var random [32]byte
rand.Read(random[:])
if C.secp256k1_schnorrsig_sign32(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&h[0])), &keypair, (*C.uchar)(unsafe.Pointer(&random[0]))) != 1 {
if C.secp256k1_schnorrsig_sign32(globalSecp256k1Context, (*C.uchar)(unsafe.Pointer(&evt.Sig[0])), (*C.uchar)(unsafe.Pointer(&evt.ID[0])), &keypair, (*C.uchar)(unsafe.Pointer(&random[0]))) != 1 {
return errors.New("failed to sign message")
}
evt.ID = h
return nil
}