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

Also FYI I spoke with Laurent offline about this:

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.

He said it could be acceptable for a core app integration if you do it that way, but overall he basically wants to keep the scope as low as possible in terms of impact to the core features.

So I think your proposal is in the right track, but there are some specific changes to the proposal I would suggest:

  1. “The proposal should be exhaustive and cover anything that can potentially be affected by the change, including CLI, web clipper, third part apps, etc” - directly stated by Laurent. Basically make sure to mention you have considered and checked every area of impact, and there are safeguards in place to prevent unwanted impact (see point 2)
  2. Mention that any new functionality added in Note save / load will only be effective, when a new use_local_encryption option field (or other suitable name) is supplied and it is set to true. Then cover which routes will include this, ie. the note viewer and editor routes on the desktop and mobile apps
  3. Define the new encrypted resource handling as discussed, ie. avoid reading the whole decrypted resource into memory, use a dedicated temp-cache directory and empty it upon starting and quitting the app on desktop, and just upon starting on the mobile app to reduce complexity, by relying on device measures to ensure the data is secured when closing the app
  4. Mention that the body will be stored as an empty string while the note is encrypted, which bypasses any risk of search indexing and revisions containing unencrypted data
  5. Mention that existing revisions will be deleted upon enabling encryption for a note, using the existing RevisionService function. Additionally, creation of new revisions will be suppressed for encrypted notes, by bypassing revision creation if is_locally_encrypted is true

Thank you so much for discussing this with Laurent! I really appreciate the guidance.

I completely agree with your point about the mobile OS relying on only clearing the temp-cache on startup being a much cleaner and more reliable compromise than fighting React Native background tasks.

I will update my GSoC proposal right now to include your points and everything we have discussed and came to a conclusion on.

Thanks again for helping me refine this! I'll make sure the proposal reflects all these safeguards perfectly.

Just to add. I’d say support for encrypted notes / notebooks should be limited to desktop and mobile. I think support on the cli would increase the scope too much, so notes encrypted on desktop / mobile would just be returned as empty notes on the cli (and api too). I think it may also be worth considering adding some kind of read only mode when encrypted notes are attempted to be edited via the cli / api though. Maybe just produce an error of some kind on note update calls if is_local_encryption is enabled on the note, to keep it simple

Actually Laurent has said that we probably should add cli support (possibly api too). So try to come up with a solution how it can be added there too. For cli I guess it can just be an interactive password input and / or an additional parameter, for api a new optional field in the payload potentially, and some suitable underlying validation.

I’m not sure whether or not cli and api include resources in the response, but maybe they could have a cut down solution if they do and are problematic

That makes perfect sense.

Data API Implementation :

  • Reading Notes (GET): Because we are saving the ciphertext to local_cipher_text and leaving the body as an empty string "" in the database, API reads are naturally protected. 3rd-party apps will simply receive a 200 OK with an empty body. They won't crash, and the ciphertext remains completely secure.

  • Writing Notes (PUT): To prevent an external app from accidentally overwriting a locked note with an empty string, any update request to an encrypted note without credentials will return a strict 423 Locked HTTP status.

  • Authentication: We can introduce an optional parameter (e.g., a Vault-Password header or payload field). If provided, the API performs the JIT decryption and returns/updates the actual plaintext.

  • Resources: For decrypted attachments, we might not even need temp files. Since Node.js HTTP responses are Writable Streams, we could probably pipe the encryptionService().decryptFile() stream directly into the Express res object, bypassing the hard drive entirely.

CLI Implementation :

  • Notes: If a user runs a command on a locked note, the CLI will detect the is_locally_encrypted flag. It will trigger a secure, interactive app().gui().prompt() in the terminal. Once provided, it will pass the { use_local_encryption: true } option to Note.load() to fetch the plaintext. For background scripts, we can add a flag like --local-password="***".

  • Resources: We can reuse the Desktop app's temp-cache logic. The CLI decrypts the file to the temp folder, lets the OS open it, and safely wipes the cache when the CLI process terminates.

I definitely want to try engineering the resource support for the CLI/API first using the methods above so the app is feature complete. However, I completely agree with your safety net... if managing these file streams in the terminal or REST endpoints becomes too problematic or threatens the timeline, we can easily fall back to a "cut-down" approach where encrypted physical resources are restricted to the Desktop/Mobile UI.

I initially overlooked that encrypted notes/notebooks could be synchronised, without the ability to decrypt the notes would just be unreadable on terminal or cli. The cli does store all the resources as usual but, in the case of pictures at least, it provides an IP to start a local web server to display the resource in a browser.

The UX of a solution should be pretty easy using the existing object selection commands with an encryption command with password (which exists with e2ee already)

Does this approach for CLI support this feature in both CLI and TUI modes?

It should work in TUI too as the app().gui().prompt() is async so using await on it will naturally pause the execution flow and hand control over to the statusBar widget for user input:

prompt(initialText = '', promptString = ':', options = null) {
		return this.widget('statusBar').prompt(initialText, promptString, options);
}

Also on your proposal:

Database Schema & "Double Encryption" Support :- Extend the SQLite tables (notes,
folders, resources) with is_locally_encrypted, local_cipher_text, and importantly,
local_master_key_id.

I think all of these need to be stored on the sync objects too, not just in the sqlite db.

Is the master key going to be shared for all encrypted notes / notebooks or set individually? Depending on whether or not it is shared may determine if local_master_key is stored on the note sync object or on the sync info object, or both. Please clarify these details in the proposal as well

The master key will be shared for all encrypted notes/notebooks.
I chose this over individual passwords per note/notebook to prevent severe password fatigue for the user, because then the user has to enter the password again and again to load the key in RAM.

The shared local_master_key itself (encrypted via the user's Local Vault Password) can be synced globally, similar to how E2EE master keys are currently synced and stored.

Also since, the implementation has evolved quite a bit from the original idea and now involves changes at the model layer, resource handling, and multiple app surfaces, I was wondering if this project might fit better as a 350-hour (large) project instead of a 175-hour (medium) one?

I wanted to check whether the larger timeline would make more sense given the expanded scope and the need to carefully handle security and backwards compatibility + the unit tests, so I can divide the timeline more independently in my proposal.

350 hours is ok, as it still fits into the 12 week schedule. If it is longer than that though, then it is a problem.

The master key will be shared for all encrypted notes/notebooks.
I chose this over individual passwords per note/notebook to prevent severe password fatigue for the user, because then the user has to enter the password again and again to load the key in RAM.

Sounds good to me. Can you provide details of how the master key is setup? Is it setup in the same place where you set the master password in encryption config, and will be listed in the encryption keys? Also please define how the password can be changed. It probably is acceptable to only allow changing the password via the desktop app, as that is the case with the master password. But if the change is trivial enough, adding a UI on the mobile app would be welcome as well.

As there is only one master key for local encrypted notes, it sounds to me like you only need the is_locally_encrypted and local_cipher_text fields on the note sync item and not local_master_key_id, unless you wanted to support multiple encryption keys like is done for E2EE, though that would add more complexity. You would need to distinguish this new master key from the other E2EE encryption keys though, possibly preventing disabling this key, or having a separate section in the E2EE config, or a separate config screen altogether.

You are right that relying solely on the is_locally_encrypted flag simplifies the database schema and reduces the sync payload. I will update the proposal to reflect this optimization!

To prevent users from confusing Cloud Sync E2EE with Local Device Locking, I was thinking of giving the Local Master Key a completely separate settings screen to setup the key + if the user try to lock the note without the key setup we can show a password promt there too to maintain good UX.

Because we are using a Master Key architecture, changing the password is highly efficient: the app simply decrypts the Local Master Key using the old password, and re-encrypts it using the new password. The individual notes themselves do not need to be touched + only the user who knew the old password can change the password.

I can scope the "Change Password" UI strictly for the Desktop app to ensure the core functionality is rock solid within the 12-week timeline. However, since the underlying cryptography logic is shared across the codebase, adding the configuration UI to the Mobile app shouldn't be too heavy. I can list the Mobile UI as a primary stretch goal in the proposal.

Ringh now I am using a :

export const SOURCE_LOCAL_VAULT = 2;

Like this while creating the key :

	let masterKey = await EncryptionService.instance().generateMasterKey(password);
	masterKey = {
		...masterKey,
		source: SOURCE_LOCAL_VAULT,
	};

No, that’s not what I meant. You should have both is_locally_encrypted and local_cipher_text in note database schema and the sync item. I’m not suggesting that you collapse the local_cipher_text into the body on the sync item. It should be kept separate like in the model and database schema, as the note entity is used both for the model and the sync item.

I’m only saying that local_master_key_id is not needed on the note database schema or the sync item.

Yes, I understand that I was saing the same thing to drop only the local_master_key_id. and keep the is_locally_encrypted and local_cipher_text

To prevent users from confusing Cloud Sync E2EE with Local Device Locking, I was thinking of giving the Local Master Key a completely separate settings screen to setup the key + if the user try to lock the note without the key setup we can show a password promt there too to maintain good UX.

Sounds fine to me.

You didn’t answer directly, but I assume your new master key would just use a single encryption key rather than supporting multiple? That would be the requirement for ditching local_master_key_id.

One other thing though is there needs to be a way to reset the password (and lose access to all encrypted notes) in case you forget your password. As per the single encryption key approach, to do this you would need to replace the encryption key with a new one, as you wont be able to decrypt it without the original password. See the reset master password screen in the encryption config of the desktop app, for an example UI to reset the master password

Yes, exactly! I apologize for missing that direct confirmation in my last reply. The architecture will strictly enforce a single active encryption key for the Local Vault.

I can mirror the existing E2EE reset UI in the Desktop app settings. The UI will display a severe warning explaining that resetting will permanently render all currently locked notes unreadable.
If the user proceeds, the app generates a new Master Key with the new password, and overwrites the existing local vault key.

Because we dropped the local_master_key_id, the app won't know if a locked note belongs to the old forgotten key or the new key until it tries to decrypt it. When a user clicks on an old note, EncryptionService.decrypt() will naturally fail and throw an error.

I will catch this specific decryption error in the Note.load() interceptor. Instead of crashing or throwing a raw error, the UI will safely render a read-only lock screen stating: "Decryption Failed: This note was locked with a previous, forgotten Vault password and cannot be recovered."

All sounds good. Can’t think of anything else now, so I think that’s everything covered. Lots of points to add, so good luck with the write up!

Also note, there is now a template for writing GSoC posts which you should follow.

Thanks for your guidance , I will update my proposal according to the template!

I have updated my proposal to the latest format and now includes all the features we have discussed :
AkshajRawat GSoC 2026 proposal for Joplin.pdf (840.0 KB)

I have one quick question. The official GitHub ideas list has Idea 7 scoped as a Medium (175-hour) project. I drafted the current timeline for a Large (350-hour) project as we have discussed earlier.

Should I submit it as a 350-hour project, or would you prefer I scale the deliverables and timeline back down to fit the original 175-hour slot?

Submitting as a 350 hour project is fine