diff options
| -rw-r--r-- | nip04.go | 149 | ||||
| -rw-r--r-- | nip04_test.go | 49 |
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 @@ | |||
| 1 | package nostr | ||
| 2 | |||
| 3 | import ( | ||
| 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>" | ||
| 20 | func (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). | ||
| 49 | func (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 | ||
| 96 | func 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 | |||
| 127 | func 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 | |||
| 137 | func 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 @@ | |||
| 1 | package nostr | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | ) | ||
| 6 | |||
| 7 | func 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 | |||
| 34 | func 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 | } | ||
