nip44.go raw
1 // Package nip44 implements version 2 of NIP-44, the versioned encrypted
2 // payload format for Nostr.
3 //
4 // A payload is base64(version || nonce || ciphertext || mac), where the
5 // plaintext has been padded with a length prefix as described in the NIP.
6 // Conversation keys are derived from a secp256k1 x-only ECDH shared secret
7 // and all message keys come from HKDF-SHA256.
8 package nip44
9
10 import (
11 "crypto/hmac"
12 "crypto/rand"
13 "crypto/sha256"
14 "crypto/subtle"
15 "encoding/base64"
16 "encoding/binary"
17 "errors"
18 "math/bits"
19
20 "git.smesh.lol/nostrino/pkg/chacha20"
21 "git.smesh.lol/nostrino/pkg/crypto/secp256k1"
22 )
23
24 const (
25 // Version is the NIP-44 payload version implemented by this package.
26 Version = 2
27
28 nonceSize = 32
29 hmacKeySize = 32
30 chachaKeySize = 32
31
32 // minPayloadChars and minPayloadBytes are the DoS guards required by
33 // the NIP: a version byte, a 32-byte nonce, a 32-byte MAC and at least
34 // one padded (>= 34 byte) ciphertext block.
35 minPayloadChars = 132
36 minPayloadBytes = 99
37
38 // minPlaintextSize is 1: empty plaintexts are invalid.
39 minPlaintextSize = 1
40 // maxPlaintextSize is 2^32 - 1, the largest length the wire format can
41 // express.
42 maxPlaintextSize = 1<<32 - 1
43 // prefixThreshold is the plaintext length at which the padding prefix
44 // switches from 2-byte u16 to 6-byte (0x0000 || u32) form.
45 prefixThreshold = 1 << 16
46
47 minPaddedSize = 32
48 )
49
50 var (
51 errInvalidKey = errors.New("nip44: invalid secret key or public key")
52 errInvalidPlaintextSize = errors.New("nip44: invalid plaintext length")
53 errInvalidPayload = errors.New("nip44: invalid payload")
54 errUnknownVersion = errors.New("nip44: unknown encryption version")
55 errInvalidMAC = errors.New("nip44: invalid MAC")
56 errInvalidPadding = errors.New("nip44: invalid padding")
57 )
58
59 // ConversationKey derives the long-term conversation key shared by two users.
60 // secret is the local 32-byte secp256k1 private key and peerPub is the peer's
61 // 32-byte x-only public key. The ECDH result is used unhashed, as required by
62 // NIP-44 (shared x coordinate -> HKDF-Extract with salt "nip44-v2").
63 func ConversationKey(secret [32]byte, peerPub [32]byte) ([32]byte, error) {
64 var key [32]byte
65 shared, ok := secp256k1.ECDH(secret, peerPub)
66 if !ok {
67 return key, errInvalidKey
68 }
69 return hkdfExtract([]byte("nip44-v2"), shared[:]), nil
70 }
71
72 // Encrypt encrypts plaintext under conversationKey using a fresh random
73 // 32-byte nonce and returns the base64 payload.
74 func Encrypt(conversationKey [32]byte, plaintext []byte) (string, error) {
75 var nonce [nonceSize]byte
76 if _, err := rand.Read(nonce[:]); err != nil {
77 return "", err
78 }
79 return EncryptWithNonce(conversationKey, nonce, plaintext)
80 }
81
82 // EncryptWithNonce encrypts plaintext under conversationKey using the given
83 // 32-byte nonce. The nonce must never be reused for the same conversation
84 // key; callers should normally use Encrypt.
85 func EncryptWithNonce(conversationKey [32]byte, nonce [32]byte, plaintext []byte) (string, error) {
86 padded, err := pad(plaintext)
87 if err != nil {
88 return "", err
89 }
90 chachaKey, chachaNonce, hmacKey := messageKeys(conversationKey, nonce)
91
92 ciphertext := make([]byte, len(padded))
93 chacha20.XORKeyStream(&chachaKey, &chachaNonce, ciphertext, padded)
94 mac := aadMAC(&hmacKey, &nonce, ciphertext)
95
96 raw := make([]byte, 0, 1+nonceSize+len(ciphertext)+32)
97 raw = append(raw, Version)
98 raw = append(raw, nonce[:]...)
99 raw = append(raw, ciphertext...)
100 raw = append(raw, mac[:]...)
101 return base64.StdEncoding.EncodeToString(raw), nil
102 }
103
104 // Decrypt decrypts a NIP-44 payload using the conversation key. It returns an
105 // error for any malformed, unauthenticated or wrongly padded payload.
106 func Decrypt(conversationKey [32]byte, payload string) ([]byte, error) {
107 // A leading '#' signals a future, non-base64 encoding.
108 if len(payload) == 0 || payload[0] == '#' {
109 return nil, errUnknownVersion
110 }
111 if len(payload) < minPayloadChars {
112 return nil, errInvalidPayload
113 }
114 data, err := base64.StdEncoding.DecodeString(payload)
115 if err != nil {
116 return nil, errInvalidPayload
117 }
118 if len(data) < minPayloadBytes {
119 return nil, errInvalidPayload
120 }
121 if data[0] != Version {
122 return nil, errUnknownVersion
123 }
124
125 var nonce [nonceSize]byte
126 copy(nonce[:], data[1:1+nonceSize])
127 ciphertextEnd := len(data) - 32
128 ciphertext := data[1+nonceSize : ciphertextEnd]
129 var mac [32]byte
130 copy(mac[:], data[ciphertextEnd:])
131
132 chachaKey, chachaNonce, hmacKey := messageKeys(conversationKey, nonce)
133 want := aadMAC(&hmacKey, &nonce, ciphertext)
134 if subtle.ConstantTimeCompare(want[:], mac[:]) != 1 {
135 return nil, errInvalidMAC
136 }
137
138 padded := make([]byte, len(ciphertext))
139 chacha20.XORKeyStream(&chachaKey, &chachaNonce, padded, ciphertext)
140 return unpad(padded)
141 }
142
143 // hkdfExtract implements HKDF-Extract (RFC 5869) with SHA-256.
144 func hkdfExtract(salt, ikm []byte) [32]byte {
145 mac := hmac.New(sha256.New, salt)
146 mac.Write(ikm)
147 var prk [32]byte
148 mac.Sum(prk[:0])
149 return prk
150 }
151
152 // hkdfExpand implements HKDF-Expand (RFC 5869) with SHA-256 for a 32-byte
153 // PRK. It panics if length exceeds the 255-block limit (8160 bytes); NIP-44
154 // only ever asks for 76 bytes.
155 func hkdfExpand(prk [32]byte, info []byte, length int) []byte {
156 if length > 255*sha256.Size {
157 panic("nip44: hkdf length too large")
158 }
159 out := make([]byte, 0, length)
160 var block []byte // T(0) is empty; each iteration hashes the previous T.
161 for counter := 1; len(out) < length; counter++ {
162 mac := hmac.New(sha256.New, prk[:])
163 mac.Write(block)
164 mac.Write(info)
165 mac.Write([]byte{byte(counter)})
166 block = mac.Sum(block[:0])
167 out = append(out, block...)
168 }
169 return out[:length]
170 }
171
172 // messageKeys derives the ChaCha20 key and nonce and the HMAC key for one
173 // message from the conversation key and 32-byte nonce.
174 func messageKeys(conversationKey [32]byte, nonce [32]byte) (key [chachaKeySize]byte, chachaNonce [chacha20.NonceSize]byte, hmacKey [hmacKeySize]byte) {
175 okm := hkdfExpand(conversationKey, nonce[:], chachaKeySize+chacha20.NonceSize+hmacKeySize)
176 copy(key[:], okm[0:chachaKeySize])
177 copy(chachaNonce[:], okm[chachaKeySize:chachaKeySize+chacha20.NonceSize])
178 copy(hmacKey[:], okm[chachaKeySize+chacha20.NonceSize:])
179 return key, chachaNonce, hmacKey
180 }
181
182 // calcPaddedLen returns the length of the padded plaintext (excluding the
183 // length prefix) for an unpadded length, as defined by NIP-44.
184 //
185 // The calculation deliberately uses 64-bit arithmetic because the result can
186 // reach 2^32, which overflows a 32-bit integer.
187 func calcPaddedLen(unpaddedLen uint64) uint64 {
188 if unpaddedLen <= minPaddedSize {
189 return minPaddedSize
190 }
191 nextPower := uint64(1) << bits.Len64(unpaddedLen-1)
192 var chunk uint64
193 if nextPower <= 256 {
194 chunk = 32
195 } else {
196 chunk = nextPower / 8
197 }
198 return chunk * ((unpaddedLen-1)/chunk + 1)
199 }
200
201 // pad prepends the length prefix and appends zero padding.
202 func pad(plaintext []byte) ([]byte, error) {
203 n := uint64(len(plaintext))
204 if n < minPlaintextSize || n > maxPlaintextSize {
205 return nil, errInvalidPlaintextSize
206 }
207 prefixLen := 2
208 if n >= prefixThreshold {
209 prefixLen = 6
210 }
211 total := uint64(prefixLen) + calcPaddedLen(n)
212 if total > uint64(maxInt()) {
213 return nil, errInvalidPlaintextSize
214 }
215 out := make([]byte, int(total))
216 if prefixLen == 6 {
217 // The zero u16 signals the extended prefix.
218 binary.BigEndian.PutUint32(out[2:6], uint32(n))
219 } else {
220 binary.BigEndian.PutUint16(out[0:2], uint16(n))
221 }
222 copy(out[prefixLen:], plaintext)
223 return out, nil
224 }
225
226 // unpad validates the length prefix, the padded length and the zero padding,
227 // then returns the plaintext slice.
228 func unpad(padded []byte) ([]byte, error) {
229 if len(padded) < 2 {
230 return nil, errInvalidPadding
231 }
232 prefixLen := 2
233 n := uint64(binary.BigEndian.Uint16(padded[0:2]))
234 if n == 0 {
235 if len(padded) < 6 {
236 return nil, errInvalidPadding
237 }
238 n = uint64(binary.BigEndian.Uint32(padded[2:6]))
239 // A short length in extended form is invalid.
240 if n < prefixThreshold {
241 return nil, errInvalidPadding
242 }
243 prefixLen = 6
244 }
245 if n < minPlaintextSize || n > maxPlaintextSize {
246 return nil, errInvalidPadding
247 }
248 if uint64(len(padded)) != uint64(prefixLen)+calcPaddedLen(n) {
249 return nil, errInvalidPadding
250 }
251 end := prefixLen + int(n)
252 for _, b := range padded[end:] {
253 if b != 0 {
254 return nil, errInvalidPadding
255 }
256 }
257 return padded[prefixLen:end], nil
258 }
259
260 // aadMAC computes HMAC-SHA256 over nonce || ciphertext, as required by
261 // NIP-44.
262 func aadMAC(key *[hmacKeySize]byte, nonce *[nonceSize]byte, ciphertext []byte) [32]byte {
263 mac := hmac.New(sha256.New, key[:])
264 mac.Write(nonce[:])
265 mac.Write(ciphertext)
266 var out [32]byte
267 mac.Sum(out[:0])
268 return out
269 }
270
271 // maxInt returns the largest value an int can hold.
272 func maxInt() int {
273 return int(^uint(0) >> 1)
274 }
275