aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-03-08 16:31:05 -0700
committerClawd <ai@clawd.bot>2026-03-08 16:31:45 -0700
commit0dcf191e7f63f35efe85afb60bcb57f4934a0acb (patch)
treeff9eac0c07389c0744401d07f033528082bab700
parent97e6a3cfb67d18273fa88ce086b3db514c7e3083 (diff)
feat: add NIP-04 encrypt/decrypt methods on Key
-rw-r--r--nip04.go149
-rw-r--r--nip04_test.go49
2 files changed, 198 insertions, 0 deletions
diff --git a/nip04.go b/nip04.go
new file mode 100644
index 0000000..1f1c245
--- /dev/null
+++ b/nip04.go
@@ -0,0 +1,149 @@
1package nostr
2
3import (
4 "crypto/aes"
5 "crypto/cipher"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "fmt"
11 "strings"
12
13 "github.com/btcsuite/btcd/btcec/v2"
14)
15
16// NIP04Encrypt encrypts a plaintext message for a recipient using NIP-04
17// (ECDH shared secret + AES-256-CBC). The sender's private key and
18// recipient's public key (64-char hex) are required.
19// Returns the ciphertext in NIP-04 format: "<base64>?iv=<base64>"
20func (k *Key) NIP04Encrypt(recipientPubHex, plaintext string) (string, error) {
21 if k.priv == nil {
22 return "", fmt.Errorf("cannot encrypt: public-only key")
23 }
24
25 shared, err := nip04SharedSecret(k.priv, recipientPubHex)
26 if err != nil {
27 return "", err
28 }
29
30 iv := make([]byte, 16)
31 if _, err := rand.Read(iv); err != nil {
32 return "", fmt.Errorf("generating iv: %w", err)
33 }
34
35 block, err := aes.NewCipher(shared)
36 if err != nil {
37 return "", fmt.Errorf("aes cipher: %w", err)
38 }
39
40 padded := pkcs7Pad([]byte(plaintext), aes.BlockSize)
41 ct := make([]byte, len(padded))
42 cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
43
44 return base64.StdEncoding.EncodeToString(ct) + "?iv=" + base64.StdEncoding.EncodeToString(iv), nil
45}
46
47// NIP04Decrypt decrypts a NIP-04 ciphertext using the receiver's private key
48// and sender's public key (64-char hex).
49func (k *Key) NIP04Decrypt(senderPubHex, ciphertext string) (string, error) {
50 if k.priv == nil {
51 return "", fmt.Errorf("cannot decrypt: public-only key")
52 }
53
54 parts := strings.SplitN(ciphertext, "?iv=", 2)
55 if len(parts) != 2 {
56 return "", fmt.Errorf("invalid NIP-04 ciphertext format")
57 }
58
59 ct, err := base64.StdEncoding.DecodeString(parts[0])
60 if err != nil {
61 return "", fmt.Errorf("decode ciphertext: %w", err)
62 }
63
64 iv, err := base64.StdEncoding.DecodeString(parts[1])
65 if err != nil {
66 return "", fmt.Errorf("decode iv: %w", err)
67 }
68
69 shared, err := nip04SharedSecret(k.priv, senderPubHex)
70 if err != nil {
71 return "", err
72 }
73
74 block, err := aes.NewCipher(shared)
75 if err != nil {
76 return "", fmt.Errorf("aes cipher: %w", err)
77 }
78
79 if len(ct)%aes.BlockSize != 0 {
80 return "", fmt.Errorf("ciphertext is not a multiple of block size")
81 }
82
83 plain := make([]byte, len(ct))
84 cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ct)
85
86 unpadded, err := pkcs7Unpad(plain)
87 if err != nil {
88 return "", fmt.Errorf("unpad: %w", err)
89 }
90
91 return string(unpadded), nil
92}
93
94// nip04SharedSecret computes the ECDH shared secret per NIP-04:
95// SHA256(privKey * recipientPubKey).x
96func nip04SharedSecret(priv *btcec.PrivateKey, recipientPubHex string) ([]byte, error) {
97 pubBytes, err := hex.DecodeString(recipientPubHex)
98 if err != nil {
99 return nil, fmt.Errorf("decode pubkey: %w", err)
100 }
101
102 // NIP-04 pubkeys are x-only (32 bytes, Schnorr). Prefix with 0x02 for
103 // compressed SEC1 parsing.
104 if len(pubBytes) == 32 {
105 pubBytes = append([]byte{0x02}, pubBytes...)
106 }
107
108 pub, err := btcec.ParsePubKey(pubBytes)
109 if err != nil {
110 return nil, fmt.Errorf("parse pubkey: %w", err)
111 }
112
113 // ECDH: scalar multiply
114 var point btcec.JacobianPoint
115 pub.AsJacobian(&point)
116 priv.Key.SetByteSlice(priv.Serialize())
117 btcec.ScalarMultNonConst(&priv.Key, &point, &point)
118 point.ToAffine()
119
120 // Shared secret is SHA256 of the x-coordinate
121 xBytes := point.X.Bytes()
122 shared := sha256.Sum256(xBytes[:])
123
124 return shared[:], nil
125}
126
127func pkcs7Pad(data []byte, blockSize int) []byte {
128 pad := blockSize - len(data)%blockSize
129 padded := make([]byte, len(data)+pad)
130 copy(padded, data)
131 for i := len(data); i < len(padded); i++ {
132 padded[i] = byte(pad)
133 }
134 return padded
135}
136
137func pkcs7Unpad(data []byte) ([]byte, error) {
138 if len(data) == 0 {
139 return nil, fmt.Errorf("empty data")
140 }
141 pad := int(data[len(data)-1])
142 if pad == 0 || pad > aes.BlockSize {
143 return nil, fmt.Errorf("invalid padding size: %d", pad)
144 }
145 if len(data) < pad {
146 return nil, fmt.Errorf("data shorter than padding")
147 }
148 return data[:len(data)-pad], nil
149}
diff --git a/nip04_test.go b/nip04_test.go
new file mode 100644
index 0000000..ff06151
--- /dev/null
+++ b/nip04_test.go
@@ -0,0 +1,49 @@
1package nostr
2
3import (
4 "testing"
5)
6
7func TestNIP04RoundTrip(t *testing.T) {
8 alice, err := GenerateKey()
9 if err != nil {
10 t.Fatal(err)
11 }
12 bob, err := GenerateKey()
13 if err != nil {
14 t.Fatal(err)
15 }
16
17 plaintext := "hello, private world!"
18
19 ct, err := alice.NIP04Encrypt(bob.Public(), plaintext)
20 if err != nil {
21 t.Fatalf("encrypt: %v", err)
22 }
23
24 got, err := bob.NIP04Decrypt(alice.Public(), ct)
25 if err != nil {
26 t.Fatalf("decrypt: %v", err)
27 }
28
29 if got != plaintext {
30 t.Fatalf("expected %q, got %q", plaintext, got)
31 }
32}
33
34func TestNIP04DifferentKeys(t *testing.T) {
35 alice, _ := GenerateKey()
36 bob, _ := GenerateKey()
37 eve, _ := GenerateKey()
38
39 ct, err := alice.NIP04Encrypt(bob.Public(), "secret")
40 if err != nil {
41 t.Fatal(err)
42 }
43
44 // Eve should not be able to decrypt
45 _, err = eve.NIP04Decrypt(alice.Public(), ct)
46 if err == nil {
47 t.Fatal("expected error decrypting with wrong key, got nil")
48 }
49}