Skip to content

Commit f8ccf3c

Browse files
committed
fix: chunked NIP-44 encryption for large files exceeding 65535-byte limit
NIP-44 has a hard 65,535-byte plaintext limit. Files larger than ~64KB would fail to sync with 'invalid plaintext size' error. Added automatic chunking that splits large payloads into 60KB chunks, encrypts each separately, and reassembles on decryption. Backward compatible with existing non-chunked encrypted events. Also fixed unused variable warning in Editor.tsx.
1 parent 290acbf commit f8ccf3c

6 files changed

Lines changed: 159 additions & 47 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "onyx",
3-
"version": "0.14.5",
3+
"version": "0.14.6",
44
"description": "Open source knowledge base and note-taking app",
55
"type": "module",
66
"scripts": {

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "onyx"
3-
version = "0.14.5"
3+
version = "0.14.6"
44
description = "Open source knowledge base and note-taking app"
55
authors = ["you"]
66
license = "MIT"

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
33
"productName": "Onyx",
4-
"version": "0.14.5",
4+
"version": "0.14.6",
55
"identifier": "com.onyxnotes.dev",
66
"build": {
77
"frontendDist": "../dist",

src/components/Editor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ const MilkdownEditor: Component<EditorProps> = (props) => {
8484
let lastEditorContent: string = '';
8585
// Source editor state: completely independent from Milkdown.
8686
// Reads/writes directly to disk, never touches Milkdown's serializer.
87-
const [sourceContent, setSourceContent] = createSignal('');
87+
const [_sourceContent, setSourceContent] = createSignal('');
8888
let sourceAutoSaveTimeout: number | null = null;
8989

9090
const saveFile = async () => {

src/lib/nostr/sync.ts

Lines changed: 154 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ import {
3939
} from './crypto';
4040
import 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

Comments
 (0)