GSoC 2026 : Local Note Encryption (Draft proposal and POC)

Also,
Regarding my proposal and some edge cases that laurent suggested I just wanted to add few things that like in my proposal I added a migration 50.ts which included a new columns one of which was local_cipher_text in the notes table.

So for a locked note the body = "" and the data is encrypted in the new column local_cipher_text

So, the search engine won't index it even if there is local master key loaded in the RAM

and for plugins the locked notes will be treated as the notes with body = "", and shouldn't that be it? because if a note is locked we won't want the plugin to see the data either way.

So, either way I think the app will tread locked note as simply a note with body = "", with local_cipher_text accessible to only places where we code it to be decrypted.

Would love to hear a suggetion about this if what I think is correct.
I will need some time to look into the huge resource encryption problem though...

So for a locked note the body = "" and the data is encrypted in the new column local_cipher_text

That sounds ok to me, and should prevent any new unencrypted data from getting where it shouldn’t be. I guess when enabling encryption for a note / notebook you could delete the note history (as is planned for the secure note plugin) and just add a warning saying that all note history for the notes to be encrypted will be deleted, and no new history will be created. I’m not sure what the indexing contains, but you may want to check if anything needs to be removed from there too.

I guess the way you suggested would require modifying Note save and load directly, but it probably would be less risky than originally anticipated if it just takes in a new option field, to only apply the new logic for code paths when that option is specified.

I see what you mean but my concern for now is that the project scope may be too broad, so it could make sense to find ways to keep it manageable. A reasonable scope is something that can be 100% done at a high level of quality during the development phase.

And unfortunately it has to be rock solid due to the fact that it's likely to touch many critical aspects of the app. We can be more flexible for features that are more disconnected from the important parts of the app (so that's why a plugin is always a good option but I understand it may not be ideal here).

I guess the way you suggested would require modifying Note save and load directly, but it probably would be less risky than originally anticipated if it just takes in a new option field, to only apply the new logic for code paths when that option is specified.

@laurent What do you think about this? You could make any new logic in Note save and load only applicable when passing a new option field, and only supply this field for the note editor and viewer flows. Therefore the new local_cipher_text field and the logic related to it would only be utilised for the flows of our choosing.

Does that not limit the affected scope? Honestly I think that making a plugin like secure notes, but which includes editing without storing the decrypted content back into the database, could potentially be a larger undertaking than integrating into the core app directly, because of the limitations of the plugin api and editor plugin.

Also akshajrawat’s idea of storing the note body as an empty string when encrypted is a great way to bypass the complexity of dealing with revisions and indexing, which you wouldn’t be able to do in a plugin alone without adding a separate column like local_cipher_text into the core app anyway, plus model and api amendments to allow setting it.

We can delete the past history once the note is toggled to locked, but we can also then start a fresh encryted history... encrypting the patch just before storing it to the database..

and vice versa, if the note is toggled to not lock anymore we can promt that all the previous history during the locked phase will be removed.. and then the normal flow of history patch storing again runs as normal..

Given that there is scope concerns, it would not be a good idea to implement any note history for encrypted notes, as it would increase the scope across critical functions, if the revision service has to conditionally use local_cipher_text. Not to mention that the diffs could be much larger if you compare the encrypted content to make the revisions.

That’s not feasible, because of the way the revision service works (revisions are not created on save). If you wanted to make revisions based on diffs of the decrypted content and then encrypt it, this would add significant scope and complexity to the project

Got it... You're completely right interfering with the background Revision Service would add way too much complexity and risk for the project timeline.

So, to lock in a realistic, secure scope for the proposal, my core app implementation will be:

  1. Focus purely on Note, Notebook, and Resource encryption natively within the core database. ( probably using option as you mentioned to be passed for running the feature selectively)
  2. Delete all past history when a note is toggled to "Locked" (prompting the user with a clear UI warning first).
  3. Explicitly bypass the RevisionService while the note remains locked (no new history is collected to avoid ciphertext diff bloat).
  4. Utilize an "on-demand" decryption approach for large resources (saving decrypted files to a secure .tmp OS cache upon user click) to prevent Electron out-of-memory crashes.

This keeps the database clean, prevents plaintext leaks, and ensures I can deliver a highly stable core feature.
does this approach sounds reasonable to you?

  1. Explicitly bypass the RevisionService while the note remains locked (no new history is collected to avoid ciphertext diff bloat).

Regarding this, in theory by keeping the body as an empty string and clearing the note history with the ResourceService.deleteHistoryForNote function at the point of enabling encryption for a note, new revisions will no longer be created anyway, due to this code:

EDIT: Actually, if you make changes to the note title or potentially when changing note metadata, it would create a revision with blank content and just the title. It should be simple enough to suppress revisions being created if the cipher text is set on a note though.

To me the overall approach sounds reasonable, it just depends if Laurent thinks the non plugin route is still feasible really.

Just one additional thing I can think of which needs considering though is temporary decrypted resource file cleanup, in case the app is closed with the note open (especially on mobile, which does not guarantee any onClose handling will be executed). There is a similar problem which needs solving with the current E2EE implementation, but the other way round (currently there is no clean up for temporary encrypted resources files) and there was performance concerns about deleting .crypted files on startup.

Got it!

Regarding this I know that apps who do encryption usually make a temp-cache folder to store the decrypted files, so if the app crash in next startup just clear the temp-cache in background.

I know that the app just save the .crypted file in the /joplin-desktop/resources/ folder right now, so clearing it on startup would need to run a loop to find the deletable .crypted file within the resource folder.

But for temp resource decryption we can just do it by using a temp-cache folder and per startup clean-up?

My current Proposal focuses on the core implementation because the encryption logic needs to intercept the model layer (save/load) and the resource pipeline, which seemed difficult to achieve purely from a plugin.

That said, if it turns out that the non-plugin route is not feasible or not desirable for the core codebase, I'm happy to explore a plugin-based approach as well. But since there has already been some prior work on a plugin solution as you mentioned before, will extending that approach would be preferable or is it already complete and no more plugin based solution is needed?

As at last I have to submit a valid proposal which make the feature practical and maintainable for Joplin.

About not creating new revisions, I did post this edit.

I agree. I definately think a core implementation would provide an overall better experience.

It’s roughly feature complete as an MVP to achieve encrypted notes, it’s just waiting for the official Joplin 3.6 release to release some finishing touches. The main caveat of the plugin though, is that you need to decrypt the note in order to edit it, which means it will be synced back to the server while you are editing. Sure you could enable E2EE in Joplin as well, but if for whatever reason the sync does not complete, it could be possible to have your extra private note still in unencrypted form when you sync another client.

Yes I have read that and will work upon it for my final proposal.

What about this feature but? Its for the problem you mentioned that if the app crashed when the resource is decrypted , the decrypted resource remain in the drive.

Probably not ok. That is exactly the approach which someone implemented to clean up .crypted files recently and the PR was rejected. See All: Fixes #9093: Delete orphan .crypted files at startup by keshav0479 · Pull Request #14544 · laurent22/joplin · GitHub

Yes I have read that pr and actually he is running a loop over the whole resource folder and scanning each file to know whether to delete it or not.

and what I am suggesting is that I will create a isolated temp-cache folder which will be used only for decrypting the encrypted resource. If the app crash and the folder remains populated with decrypted resource data, the app simply wipes it off without needing any loop or scanning whether it is safe to delete or not (since it should be empty either way if the app just started)

Here are benchmark testings 1 :-


2 :-


what I am suggesting is that I will create a isolated temp-cache folder which will be used only for decrypting the encrypted resource

And where would that be on the portable desktop app? If you want to keep the app portable in portable mode, it would need to be on the same drive as the installation. It was closed because of concerns over performance in particular if running the portable app on a usb drive or network drive

To maintain strict portability, the temp-cache would absolutely need to live inside the portable installation directory (e.g., inside Joplin's existing Setting.value('tempDir')). We cannot use the host OS's temp folder, otherwise we'd leave sensitive traces behind on the host machine.

Regarding the performance on slow USB/Network drives, there is a major mechanical difference between PR #14544 and this approach:

  • That PR relied on fsDriver().readDirStats(resourceDir). On a slow USB drive, if a user has 10,000 resources, the drive has to perform 10,000 reads just to build the file list in memory before it can even filter for 20-50 .crypted files.

  • Isolated temp-cache folder doesn't require traversal. It will only ever contain the 20-50 files the user unlocked during their previous session. On startup, we don't scan anything; we just issue a direct fs.emptyDir() (or equivalent rm -rf) on that specific folder.

Thank you for explaining. That approach for cleaning the decrypted resources sounds good.

Regarding the point in your proposal about reading the file buffer and deleting it in a finally block, I’m not confident that is the right solution. According to AI, when you have an image / file rendered on a screen, it’s not necessarily the case that the whole file is read into memory, but if you use fsdriver.readFile then it would read the whole file into memory. Also, say you have a pdf, unless you have pdf rendering enabled, there would only be link to open the resource in the note, rather than rendering it directly. I’m not sure whether the file is read upon creating the embedded link, when opening it, or both. Do you have another suggestion for managing deletion of resources when exiting a note? On the desktop app it’s not as straightforward and cleaning up when exiting a note, because you can have multiple windows with different notes open.

Also for reference, the secure notes plugin does not encrypt resources at all, so I have no idea how feasible it would be to do it via the plugin route.

You are absolutely right and thanks for pinpointing these issues. I was looking through a newer approach to handle resources and after reading your comment here is the final workflow I designed :-

  • For resource encryption the encrypted resources will be saved in their exact path they were before in the resource folder.
  • Yes its not right, so what I can do is instead of reading file buffer we can decrypt the resource directly in the temp-cache folder by using the encryptionService().decryptFile() which already decrypt the resource in chunks making is more efficient and give the app the decrypted-filePath to render.

Resource does not get read while creating an embedd (e.g., <img src="local-vault://resource_id"> or <a href="local-vault://resource_id">). There are 2 different ways of how it will be handled :

FOR IMAGES :

Unless the image is in visible screen the editor treats it just as a normal embedd once its in the visible screen it asks the app for the image. (No need to handle this its the default behavior)

Here the app checks -> is the image locally_encrypted? If yes -> decrypt the resource chunk by chunk in temp-cache and hand it to the ui. No buffer.

FOR PDF:

When the pdf is clicked this line of code handles it :

const openItemById = async (itemId: string, hash?: string) => {
	logger.info(`Navigating to item ${itemId}`);
	const item: BaseItemEntity = await BaseItem.loadItemById(itemId);

	if (item.type_ === ModelType.Note) {
		await goToNote(itemId, hash);
	} else if (item.type_ === ModelType.Resource) {
// here this part 
		await showResource(item);
	} else {
		throw new Error(`Unsupported item type for links: ${item.type_}`);
	}
};

Simply intercept it with the same logic, is the pdf locally encrypted? YES -> decrypt in temp-cache -> Opens pdf
If the render pdf is on, it lies in the same section as the images so it is already handled in images section.

FOR VIDEOS:

It's either clickale or will be displayed, No new code needed the above 2 already handles this too.

You are correct about finally block being too risky and also about the multiple windows open edge case.
So the best way to handle it is deleting the temp-cache folder on the app's clean exit.

This solves the problem you mentioned, if both windows has same note open and one window closes no finally block runs now so other windows are still working + If the user again and again switch notes extra CPU will not be used to again and again decrypt the resource for the current session making the performance more better.

In case of crash, at the startup (begning of each new session) run a small lighweight command to just wipe off the temp-cache and start fresh with an empty folder. (The method I discussed before it)

As mentioned earlier you can’t rely on any onClose handling for the mobile app. Are you suggesting on desktop we handle on close, and on mobile handle differently? Maybe set a flag when any resources were decrypted as part of opening an encrypted note, and if the flag is set, empty the temp-cache dir upon unmounting the note viewer / editor screen?

You are right yes, we can use that in mobile kind of like hybrid checks.
We can delete the temp-cache when the noteeditor unmounts + for crash handling the on startup cleanup.

In desktop/cli, editor unmount clearning can be risky because different windows can be open simultaneously so, on clean exit cleanup + startup cleanup

Edit : I think removing decrypted file on each unmount will be too heavy for performance bacause if a user move from note1 to note2 to note1 again, note1 resource will be decrypted twice. I think instead we can do is use an event listner for app state in react native to check if the app state is 'background' or 'inactive', then only wipe the temp-cache

Edit : I think removing decrypted file on each unmount will be too heavy for performance bacause if a user move from note1 to note2 to note1 again, note1 resource will be decrypted twice.

That’s a fair point, if you follow note links in a note, then it’s not ideal to clear resources and decrypt again between each note navigation.

I think instead we can do is use an event listner for app state in react native to check if the app state is 'background' or 'inactive', then only wipe the temp-cache

Maybe not. As a React Native app, you cannot do any activity in the background without introducing native code, or some third party library with unreliable results. You could make clearing happen when navigating to the note list screen (either backward, or forward via the reveal in notebook option), but I’m thinking actually maybe we should only clear the decrypted resources on app start on mobile, and not bother cleaning at any other point, to avoid additional complexity. This is because unless you root / jailbreak your device, the app specific data directory cannot be accessed outside of the app (not the case on desktop, but it is feasible to handle on quit on the desktop app). So maybe it could be a reasonable compromise on mobile, to only clear upon starting the app afresh. What do you think?