@@ -39,6 +39,19 @@ import {
3939} from './crypto' ;
4040import type { NostrSigner } from './signer' ;
4141
42+ /**
43+ * NIP-44 has a hard limit of 65,535 bytes for plaintext.
44+ * We use a safe margin below that to account for UTF-8 multi-byte characters.
45+ */
46+ const NIP44_CHUNK_SIZE = 60000 ;
47+
48+ /**
49+ * Prefix used to identify chunked encrypted payloads.
50+ * When decrypting, if the first chunk decrypts to a string starting with this prefix,
51+ * the content is treated as a chunked payload that needs reassembly.
52+ */
53+ const CHUNKED_PREFIX = '{"_chunked":true,"total":' ;
54+
4255/**
4356 * Sync Engine class
4457 */
@@ -170,37 +183,158 @@ export class SyncEngine {
170183 }
171184
172185 /**
173- * Encrypt content using NIP-44
174- * For NIP-46, uses remote encryption; for local, uses conversation key
186+ * Low-level NIP-44 encrypt (single chunk, must be <=65535 bytes)
175187 */
176- private async encryptContent ( plaintext : string ) : Promise < string > {
177- if ( this . signer ?. nip44 && this . pubkey ) {
178- // Use signer's NIP-44 (works for both local and remote)
179- return await this . signer . nip44 . encrypt ( this . pubkey , plaintext ) ;
180- } else if ( this . conversationKey ) {
181- // Legacy: use conversation key directly
188+ private async encryptSingle ( plaintext : string , recipientPubkey ?: string ) : Promise < string > {
189+ const targetPubkey = recipientPubkey ?? this . pubkey ;
190+ if ( this . signer ?. nip44 && targetPubkey ) {
191+ return await this . signer . nip44 . encrypt ( targetPubkey , plaintext ) ;
192+ } else if ( ! recipientPubkey && this . conversationKey ) {
182193 return nip44 . v2 . encrypt ( plaintext , this . conversationKey ) ;
194+ } else if ( recipientPubkey && this . identity ) {
195+ const conversationKey = nip44 . v2 . utils . getConversationKey (
196+ hexToBytes ( this . identity . privkey ) ,
197+ recipientPubkey
198+ ) ;
199+ return nip44 . v2 . encrypt ( plaintext , conversationKey ) ;
183200 } else {
184201 throw new Error ( 'No encryption method available' ) ;
185202 }
186203 }
187204
188205 /**
189- * Decrypt content using NIP-44
190- * For NIP-46, uses remote decryption; for local, uses conversation key
206+ * Low-level NIP-44 decrypt (single chunk)
191207 */
192- private async decryptContent ( ciphertext : string ) : Promise < string > {
193- if ( this . signer ?. nip44 && this . pubkey ) {
194- // Use signer's NIP-44 (works for both local and remote)
195- return await this . signer . nip44 . decrypt ( this . pubkey , ciphertext ) ;
196- } else if ( this . conversationKey ) {
197- // Legacy: use conversation key directly
208+ private async decryptSingle ( ciphertext : string , senderPubkey ?: string ) : Promise < string > {
209+ const targetPubkey = senderPubkey ?? this . pubkey ;
210+ if ( this . signer ?. nip44 && targetPubkey ) {
211+ return await this . signer . nip44 . decrypt ( targetPubkey , ciphertext ) ;
212+ } else if ( ! senderPubkey && this . conversationKey ) {
198213 return nip44 . v2 . decrypt ( ciphertext , this . conversationKey ) ;
214+ } else if ( senderPubkey && this . identity ) {
215+ const conversationKey = nip44 . v2 . utils . getConversationKey (
216+ hexToBytes ( this . identity . privkey ) ,
217+ senderPubkey
218+ ) ;
219+ return nip44 . v2 . decrypt ( ciphertext , conversationKey ) ;
199220 } else {
200221 throw new Error ( 'No decryption method available' ) ;
201222 }
202223 }
203224
225+ /**
226+ * Split plaintext into chunks that fit within NIP-44's 65535-byte limit.
227+ * Uses TextEncoder to measure actual UTF-8 byte length.
228+ */
229+ private splitIntoChunks ( plaintext : string ) : string [ ] {
230+ const encoder = new TextEncoder ( ) ;
231+ const bytes = encoder . encode ( plaintext ) ;
232+ if ( bytes . length <= NIP44_CHUNK_SIZE ) {
233+ return [ plaintext ] ;
234+ }
235+ const chunks : string [ ] = [ ] ;
236+ const decoder = new TextDecoder ( ) ;
237+ let offset = 0 ;
238+ while ( offset < bytes . length ) {
239+ let end = Math . min ( offset + NIP44_CHUNK_SIZE , bytes . length ) ;
240+ // Avoid splitting in the middle of a multi-byte UTF-8 character
241+ while ( end > offset && ( bytes [ end - 1 ] & 0xC0 ) === 0x80 ) {
242+ end -- ;
243+ }
244+ // Also step back past the leading byte of the split character
245+ if ( end > offset && end < bytes . length && ( bytes [ end ] & 0xC0 ) === 0x80 ) {
246+ while ( end > offset && ( bytes [ end ] & 0xC0 ) !== 0xC0 ) {
247+ end -- ;
248+ }
249+ }
250+ chunks . push ( decoder . decode ( bytes . slice ( offset , end ) ) ) ;
251+ offset = end ;
252+ }
253+ return chunks ;
254+ }
255+
256+ /**
257+ * Encrypt content using NIP-44 with automatic chunking for large payloads.
258+ * For NIP-46, uses remote encryption; for local, uses conversation key.
259+ *
260+ * If the plaintext exceeds NIP-44's 65535-byte limit, it is split into chunks.
261+ * Each chunk is encrypted individually. The first chunk contains a JSON header
262+ * with metadata, and subsequent chunks contain the payload parts.
263+ * The encrypted chunks are joined with a '.' delimiter.
264+ */
265+ private async encryptContent ( plaintext : string ) : Promise < string > {
266+ return this . encryptChunked ( plaintext ) ;
267+ }
268+
269+ /**
270+ * Decrypt content using NIP-44 with automatic chunk reassembly.
271+ * For NIP-46, uses remote decryption; for local, uses conversation key.
272+ */
273+ private async decryptContent ( ciphertext : string ) : Promise < string > {
274+ return this . decryptChunked ( ciphertext ) ;
275+ }
276+
277+ /**
278+ * Encrypt with chunking support, optionally to a specific recipient pubkey.
279+ */
280+ private async encryptChunked ( plaintext : string , recipientPubkey ?: string ) : Promise < string > {
281+ const chunks = this . splitIntoChunks ( plaintext ) ;
282+ if ( chunks . length === 1 ) {
283+ // Small enough for a single NIP-44 encryption
284+ return this . encryptSingle ( plaintext , recipientPubkey ) ;
285+ }
286+ // Encrypt each chunk separately
287+ const encryptedParts : string [ ] = [ ] ;
288+ // First part is a header containing the total chunk count
289+ const header = `{"_chunked":true,"total":${ chunks . length } }` ;
290+ encryptedParts . push ( await this . encryptSingle ( header , recipientPubkey ) ) ;
291+ for ( const chunk of chunks ) {
292+ encryptedParts . push ( await this . encryptSingle ( chunk , recipientPubkey ) ) ;
293+ }
294+ // Join with '.' delimiter (base64 NIP-44 ciphertexts never contain '.')
295+ return encryptedParts . join ( '.' ) ;
296+ }
297+
298+ /**
299+ * Decrypt with automatic chunk detection and reassembly.
300+ */
301+ private async decryptChunked ( ciphertext : string , senderPubkey ?: string ) : Promise < string > {
302+ // Check if this looks like a chunked payload (multiple base64 segments joined by '.')
303+ // NIP-44 ciphertexts are base64-encoded and don't contain '.' characters
304+ if ( ! ciphertext . includes ( '.' ) ) {
305+ return this . decryptSingle ( ciphertext , senderPubkey ) ;
306+ }
307+ const parts = ciphertext . split ( '.' ) ;
308+ if ( parts . length < 2 ) {
309+ return this . decryptSingle ( ciphertext , senderPubkey ) ;
310+ }
311+ // Try to decrypt the first part as a header
312+ let header : string ;
313+ try {
314+ header = await this . decryptSingle ( parts [ 0 ] , senderPubkey ) ;
315+ } catch {
316+ // If the first part doesn't decrypt, this isn't a chunked payload
317+ return this . decryptSingle ( ciphertext , senderPubkey ) ;
318+ }
319+ if ( ! header . startsWith ( CHUNKED_PREFIX ) ) {
320+ // Not a chunked payload - the '.' might have been in the ciphertext for another reason
321+ // Try decrypting the whole thing as a single value
322+ return this . decryptSingle ( ciphertext , senderPubkey ) ;
323+ }
324+ // Parse the header to get the total chunk count
325+ const meta = JSON . parse ( header ) as { _chunked : boolean ; total : number } ;
326+ const expectedParts = meta . total ;
327+ if ( parts . length !== expectedParts + 1 ) {
328+ throw new Error ( `Chunked payload has ${ parts . length - 1 } data parts but header says ${ expectedParts } ` ) ;
329+ }
330+ // Decrypt each data chunk and reassemble
331+ const decryptedChunks : string [ ] = [ ] ;
332+ for ( let i = 1 ; i < parts . length ; i ++ ) {
333+ decryptedChunks . push ( await this . decryptSingle ( parts [ i ] , senderPubkey ) ) ;
334+ }
335+ return decryptedChunks . join ( '' ) ;
336+ }
337+
204338 /**
205339 * Send a NIP-17 private direct message
206340 * Uses gift-wrapped sealed messages for privacy
@@ -699,40 +833,18 @@ export class SyncEngine {
699833
700834 /**
701835 * Encrypt content to a recipient's pubkey (for sharing)
836+ * Automatically chunks large payloads that exceed NIP-44's limit.
702837 */
703838 private async encryptToRecipient ( plaintext : string , recipientPubkey : string ) : Promise < string > {
704- if ( this . signer ?. nip44 ) {
705- // Use signer's NIP-44 to encrypt to recipient
706- return await this . signer . nip44 . encrypt ( recipientPubkey , plaintext ) ;
707- } else if ( this . identity ) {
708- // Legacy: compute conversation key with recipient and encrypt
709- const conversationKey = nip44 . v2 . utils . getConversationKey (
710- hexToBytes ( this . identity . privkey ) ,
711- recipientPubkey
712- ) ;
713- return nip44 . v2 . encrypt ( plaintext , conversationKey ) ;
714- } else {
715- throw new Error ( 'No encryption method available' ) ;
716- }
839+ return this . encryptChunked ( plaintext , recipientPubkey ) ;
717840 }
718841
719842 /**
720843 * Decrypt content from a sender's pubkey (for receiving shared docs)
844+ * Automatically reassembles chunked payloads.
721845 */
722846 private async decryptFromSender ( ciphertext : string , senderPubkey : string ) : Promise < string > {
723- if ( this . signer ?. nip44 ) {
724- // Use signer's NIP-44 to decrypt from sender
725- return await this . signer . nip44 . decrypt ( senderPubkey , ciphertext ) ;
726- } else if ( this . identity ) {
727- // Legacy: compute conversation key with sender and decrypt
728- const conversationKey = nip44 . v2 . utils . getConversationKey (
729- hexToBytes ( this . identity . privkey ) ,
730- senderPubkey
731- ) ;
732- return nip44 . v2 . decrypt ( ciphertext , conversationKey ) ;
733- } else {
734- throw new Error ( 'No decryption method available' ) ;
735- }
847+ return this . decryptChunked ( ciphertext , senderPubkey ) ;
736848 }
737849
738850 /**
0 commit comments