From cc7637b833bb66cd8715d466e615a23ec0f05c92 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 8 Mar 2026 16:32:53 -0700 Subject: feat: NIP-04 encrypt/decrypt using internal secp256k1 --- internal/secp256k1/point.go | 12 ++++++++++++ nip04.go | 46 ++++++++++++++++++++------------------------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/internal/secp256k1/point.go b/internal/secp256k1/point.go index 1def176..5215590 100644 --- a/internal/secp256k1/point.go +++ b/internal/secp256k1/point.go @@ -162,6 +162,18 @@ func (p *Point) Negate() *Point { return &Point{x: p.x.Clone(), y: negY, infinity: false} } +// XBytes returns the x-coordinate as a 32-byte big-endian slice. +// Returns nil for the point at infinity. +func (p *Point) XBytes() []byte { + if p.infinity { + return nil + } + b := p.x.value.Bytes() + out := make([]byte, 32) + copy(out[32-len(b):], b) + return out +} + // String returns a readable representation func (p *Point) String() string { if p.infinity { diff --git a/nip04.go b/nip04.go index 1f1c245..f979bcd 100644 --- a/nip04.go +++ b/nip04.go @@ -10,13 +10,13 @@ import ( "fmt" "strings" - "github.com/btcsuite/btcd/btcec/v2" + "code.northwest.io/nostr/internal/secp256k1" ) -// NIP04Encrypt encrypts a plaintext message for a recipient using NIP-04 -// (ECDH shared secret + AES-256-CBC). The sender's private key and -// recipient's public key (64-char hex) are required. -// Returns the ciphertext in NIP-04 format: "?iv=" +// NIP04Encrypt encrypts plaintext for a recipient using NIP-04 +// (ECDH shared secret + AES-256-CBC). +// recipientPubHex is the recipient's x-only public key as 64-char hex. +// Returns ciphertext in NIP-04 format: "?iv=" func (k *Key) NIP04Encrypt(recipientPubHex, plaintext string) (string, error) { if k.priv == nil { return "", fmt.Errorf("cannot encrypt: public-only key") @@ -45,7 +45,7 @@ func (k *Key) NIP04Encrypt(recipientPubHex, plaintext string) (string, error) { } // NIP04Decrypt decrypts a NIP-04 ciphertext using the receiver's private key -// and sender's public key (64-char hex). +// and the sender's x-only public key as 64-char hex. func (k *Key) NIP04Decrypt(senderPubHex, ciphertext string) (string, error) { if k.priv == nil { return "", fmt.Errorf("cannot decrypt: public-only key") @@ -91,37 +91,31 @@ func (k *Key) NIP04Decrypt(senderPubHex, ciphertext string) (string, error) { return string(unpadded), nil } -// nip04SharedSecret computes the ECDH shared secret per NIP-04: -// SHA256(privKey * recipientPubKey).x -func nip04SharedSecret(priv *btcec.PrivateKey, recipientPubHex string) ([]byte, error) { +// nip04SharedSecret computes the NIP-04 ECDH shared secret: +// SHA256( (privKey * recipientPubKey).x ) +func nip04SharedSecret(priv *secp256k1.PrivateKey, recipientPubHex string) ([]byte, error) { pubBytes, err := hex.DecodeString(recipientPubHex) if err != nil { return nil, fmt.Errorf("decode pubkey: %w", err) } - // NIP-04 pubkeys are x-only (32 bytes, Schnorr). Prefix with 0x02 for - // compressed SEC1 parsing. - if len(pubBytes) == 32 { - pubBytes = append([]byte{0x02}, pubBytes...) - } - - pub, err := btcec.ParsePubKey(pubBytes) + // Expect 32-byte x-only key + pub, err := secp256k1.ParsePublicKeyXOnly(pubBytes) if err != nil { return nil, fmt.Errorf("parse pubkey: %w", err) } - // ECDH: scalar multiply - var point btcec.JacobianPoint - pub.AsJacobian(&point) - priv.Key.SetByteSlice(priv.Serialize()) - btcec.ScalarMultNonConst(&priv.Key, &point, &point) - point.ToAffine() + // ECDH: scalar multiply — shared point = priv.D * recipientPub + shared := pub.Point.ScalarMul(priv.D) - // Shared secret is SHA256 of the x-coordinate - xBytes := point.X.Bytes() - shared := sha256.Sum256(xBytes[:]) + // Shared secret = SHA256(x-coordinate, big-endian, 32 bytes) + xBytes := shared.XBytes() + if xBytes == nil { + return nil, fmt.Errorf("ECDH result is point at infinity") + } - return shared[:], nil + h := sha256.Sum256(xBytes) + return h[:], nil } func pkcs7Pad(data []byte, blockSize int) []byte { -- cgit v1.2.3