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

Can you upload the new version in the top post of this thread as well?

i am unable to edit the post

I've increased your trust level now so you should be able to edit it, if not let me know

@akshajrawat Thank you for your updates to the proposal. I've read through it and have some more comments:

The following action points from my comments have not been addressed anywhere in your proposal:

  • 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 (with the updates to your proposal to include cli and api, there are additional routes now as well)
  • 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

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

In your proposal you say that the local key is excluded from the sync. What is the difference between this local key and the shared master key which you mention is synced?

Schema Addition: We will introduce an is_locally_encrypted (integer 0 or 1) column and a
local_cipher_text column to the notes and folder tables

Regarding this point, is local_cipher_text required for the folder table or just on the note table?

Safety Fetch for Auto-Saves: During partial updates (background auto-saves), a
"Safety Fetch" queries the oldNote to verify its encryption status. This guarantees
that background processes do not accidentally overwrite a locked note with
plaintext.

The 'oldNote' related logic in Note.save is used for creating revisions, and gets stored in the item_changes table. In order to avoid leaking unencrypted data into the database, where is_locally_encrypted is true, you should just skip the logic to populate beforeChangeItemJson so that it is set as null

Inline Media (Images/Videos): We will leverage the browser engine's native lazy-loading.
When Chromium natively requests a resource as it enters the viewport, we will intercept this
GET request at Joplin's internal resource server. The server will stream-decrypt the requested
resource file into the temp-cache and pipe the plaintext media back to the UI

I think you should make it clearer in this section, that the temp-cache folder is dedicated folder for storing temporary decrypted attachments in the desktop and mobile apps

Sidebar Metadata Decryption: When the user unlocks the Notebook, only the metadata
(e.g., note titles and updated timestamps) is decrypted into RAM using the existing
functions. This allows the sidebar/NoteList to populate quickly without heavy processing.

The way this is worded implies that the metadata will be encrypted in addition to the note body. But you haven't mentioned details about this anywhere else, if this is the case. I mentioned in an earlier post that encrypting titles is not a necessity, so I'm happy to drop this from the scope of the project. But I do like the idea that you cannot see the contained note list or notebook hierarchy within a notebook when it is encrypted, which is indeed possible with just an is_locally_encrypted flag set to true, in addition to the notes themselves being flagged as encrypted.

This does raise an additional question though, will nested notebooks be encrypted as well? Would it be practical to recursively encrypt all child notebooks and notes when enabling encryption on a notebook?

Active Note Decryption: The heavy lifting, decrypting the actual markdown body and
dynamic resource attachment is on wait

What do you mean 'is on wait'?

Data Corruption During Sync (Double-Encryption): When the Cloud Sync engine downloads
an already-locked note from another device, it calls Note.save() to write it to the local
database. If the Object Firewall intercepts this, it might attempt to re-encrypt the incoming
payload, corrupting the valid local_cipher_text. Mitigation: The Sync engine will pass a {
skipLocalVaultIntercept: true } flag. This allows incoming sync payloads (which already safely
contain the correct is_locally_encrypted and local_cipher_text values
) to bypass the local
encryption lifecycle and write directly to the SQLite database untouched.

What about if there is an incoming change which came from an outdated client? Therefore is_locally_encrypted and local_cipher_text values wont be present on the sync item. How will you handle this scenario?

Mobile Configuration UI: While the core decryption logic will natively support the React
Native Mobile app, the actual "Setup" and "Change Password" configuration screens are
currently scoped for Desktop

What is the 'setup' screen? You have not defined this anywhere else in the proposal as far as I can see.

Please also update the first post on this thread after making the necessary amendments. Ideally if you could version the filename as well, it would make it clearer when it is updated.

Some additional points:

  • While I did initially like the idea of hiding the contents of a locked notebook until unlocked, I think this is going to be misleading if you don’t encrypt child note and nested notebook names as well, as it gives the impression everything inside is encrypted. Doing so would add a load of additional complexity and scope, including adding the need to filter items shown in the notebook list, filtering notebooks eligible as a target to move to, excluding / clearing encrypted notebooks stored in the activeFolder setting when the notebook is locked etc. Due to this I think that locking a notebook should lock all nested notes and notebooks recursively, but they should all still be visible (but with the padlock icon visible on all child notebooks as well) and no password prompt is presented when opening the notebook, but just when opening notes within it

  • If a notebook is locked, there should be restrictions to prevent the user from removing the lock from any of the child notes / notebooks, as this would break the concept of the locked notebook being an encrypted area. Additionally or alternatively to restricting removing lock on notes within a locked notebook, would it make sense to have a visual indicator (padlock) shown on notes in the note list, and not just on notebooks? If not enforcing a restriction, you could have an additional icon state for some but note all notes in the notebook are encrypted. Adding onto the previous point, this could make it more like a bulk encrypt notes feature rather than notebook encryption, which is not the same but still comparable in usefulness and simpler to implement. But in that case it would be lower impact to just ditch notebook level padlock icons and have the icon on notes only, and just have additional notebook level options of “lock all notes in this notebook” and “unlock all notes in this notebook” which which lock or unlock all child notes recursively

  • The proposal does not give details about how it would be handled if notes / notebooks are moved in or out of a locked notebook. Especially on mobile, you can change the notebook using a dropdown at the top of the edit note screen, which would require swapping the note contents between the body and the cipher text fields on the fly. There probably should be some kind of warning when moving between locked / not locked notebooks. Additionally, when you create a note it will create it in a notebook based on the activeFolderId setting (usually the last opened notebook), so you would need to initialise the note in encrypted mode if the initial notebook is an encrypted one. Note creation and moving would need to be considered for all of desktop, mobile, cli and api

EDIT: I’ve mainly been talking through my thought process in this post, but looking at it I think a true encrypted notebook feature is going to be too big for the scope of this project. Instead, implementing a recursive encrypt / decrypt all notes in notebook feature, and having note level visual indicators instead of notebook level visual indicators I think would be more suitable within the scope of the project. It then also makes the concern in my last point redundant, if there is no concept of an encrypted notebook to create notes or move notes between

This line "this architecture explicitly isolates the local key from Joplin’s cloud sync engine." was totally mistake from my side as I have written this proposal in new format before the things were discussed but I forgot to update the what will be implemented part. To clear this up, I will rename this entirely to the Synced Local-Vault Key to differentiate it from standard E2EE keys.

Sorry, It was actually is_locally_encrypted and local_cipher_text for notes and is_locally_encrypted for folder, I should've written more deeply.

Regarding folder shema I will mention a query in reply of your next comment, so please consider it.

I have updated the Object Firewall section of the proposal to explicitly skip the population of beforeChangeItemJson (setting it to null) whenever is_locally_encrypted is true. Additionally, as you mentioned in a related point, I've added a step to clear existing plaintext revisions when encryption is first enabled on a note.

Got it!

By on-wait I meant the existing lazy loading of notes in the app (Not all the notes are decrypted only the note which is active is decrypted through the Note.load function).

OH this makes sense and I've completely missed this point... I think for this we have to introduce some more checks while getting the sync payload :

  • If the sync payload does not have the property is_locall_encrypted or local_cipher_text we would know that it is coming form an outdated client.
  • if the body of the incoming payload with outdated client is empty (potentially only the title was changed) we can merge the oldNote (having both field) and the new payload. So, the new changes title will presist.
  • If there is some body changes, I think using joplin's conflict resolution system by creating a new note in conflict folder be the best idea?

By setup I meant the new setting interface for only the local key that we have agreed above on.
This one :

I will make it more clearer in the proposal.
Thanks for feedbacks.

By what I have understood instead of individually locking one notebook and making the implementation process more complex we can shift toward a lock-all-notebook and unlock-all-notebook approach, which will recursively lock all notes inside it just the way it does right now?

So, we don't have to include the is_locally_encypted column in the folder table too now.

Adittionally, this will solve the problem of moving notes in and out, as locked notes will remain locked and unlocked notes will remain unlocked when moving in and out of the note?

Thanks for discussing this draft proposal in such depth. I haven't read everything in detail, but I can see there's a lot of care and thought put into it.

One important point I'd like to emphasise again is that the proposal must prioritise a low impact on the core codebase. Even a good implementation may not be merged if it ends up being too invasive or too risky. We can't break sync or compromise performances for hundreds of thousands of people just for one feature. I get that plugin is not the right approach here, but the design should still aim for something that can be integrated incrementally or with limited surface impact.

You might already have this in your draft proposal, in which case ignore me, but basically keep it in your mind as it is important. A feature that works is not the same as a feature that can be merged.

This line "this architecture explicitly isolates the local key from Joplin’s cloud sync engine." was totally mistake from my side as I have written this proposal in new format before the things were discussed but I forgot to update the what will be implemented part. To clear this up, I will rename this entirely to the Synced Local-Vault Key to differentiate it from standard E2EE keys.

You need to update the diagram as well then, to remove the part about not syncing the key?

By on-wait I meant the existing lazy loading of notes in the app (Not all the notes are decrypted only the note which is active is decrypted through the Note.load function).

Could you make this clearer in the proposal please.

Yes to all these points. You understand correctly.

There is a couple of other points in your comment which I'll come back to, but first I'll mention something which affects both of those points. You proposed that in order by bypass the revision service and search indexing you would store the note body as an empty string for encrypted notes, and store the encrypted content in a separate local_cipher_text column. While that is a safe approach for storing the data in the database, however wherever the note content is read to the UI, it will mount the note body field and execute effects based on changes to this value. Therefore in order to display the decrypted content, the content must be set in its decrypted form on the body field when it is set on the model (note entity), at least for the relevant paths. However, there are likely places in the code where data could be leaked into the database if containing the unencrypted data in the model. Bear in mind also that Note.load is not the only way to load the note, but for example the BaseModel.byId function could also be used to load the note. This is a major issue that would need to be solved in order for the scope to get out of hand, and without delving deep into the code paths, I'm unsure of a way forward to this, to implement it in a clean way with low impact. I'm open to new ideas on your part.

To continue with responding to your points:

If the sync payload does not have the property is_locall_encrypted or local_cipher_text we would know that it is coming form an outdated client.

This raises a bigger question. There is no way to enforce that other clients must be updated if you update 1 client but not all of them (actually there is using sync target upgrades, but Laurent says we shouldn't use it anymore as it can cause problems). If you were to edit an encrypted note on an old client, the new is_locally_encrypted and local_cipher_text values would be wiped from the server object by the sync, which would therefore purge your encrypted note data completely. To solve this, you could put the encrypted body into the body field on the server item (and don't include the local_cipher_text field), but you still need a way to determine that it is an encrypted note without the addition of the is_locally_encrypted field to the server item.

I'd suggest doing the following: When mapping to and from the server item, a 'JOPLIN_CIPHER:' prefix followed by the cipher text should be stored on the note body on set, and the prefix is removed on get. This marker will be a fallback so that if the is_locally_encrypted field is not included on the server item upon downloading it from the server, where the body contains the 'JOPLIN_CIPHER:' prefix before removing it, is_locally_encrypted will be set to true on the model, so that it will be upgraded to the new schema again the next time it is changed on the same client.

There is an edge case that if a user has JOPLIN_CIPHER: as the start of the body of a note and they edit that note on an old client while also using a newer client as well, then Joplin would think it is an encrypted note and try to decrypt it in newer versions. But it's very unlikely both those conditions would be true. This would however mean that non updated clients will see the encrypted text directly, but it's only the encrypted content, so it's not a privacy risk even if it does add some crap in the revisions / indexing. The fact that they will see non human readable content should give some hint to the user that they need to upgrade

If there is some body changes, I think using joplin's conflict resolution system by creating a new note in conflict folder be the best idea?

Hmm, conflicts adds further complication to the solution. You can only meaningfully resolve a conflict if the note is decrypted, which then extends scope further. We also have another GSoC project this year for automatic / assisted conflict resolution, which could conflict (no pun intended) with whatever is implemented here regarding conflict resolution, as its been suggested for that project, that git style conflict markers will be added inline to make a merged note body, which will prevent the notes from being decryptable. I think how conflicts are handled in this scenario is more of a discussion for the conflicts project, but regarding the double encryption problem, you need to shy away anything to do with the conflicts and also can't solely rely on using whether the is_locally_encrypted and local_cipher_text columns are populated to drive this, though you may be able to utilise that if you also consider the fallback logic in my last point

Also to expand on the last point, I’m not sure protection against double encryption actually needs any additional core logic?

There are already checks all over the place which skip processing when encryption_applied is set eg:

For Note.save, if double encryption were an issue with local encryption enabled, there would also be an issue with accidentally replacing a plain text note with encrypted contents, so I don’t think you need to do anything to handle it.

For Note.load (and any other load routes if necessary to update), to be on the safe side, you can just skip the local decryption logic entirely when encryption_applied is undefined or set to true, and existing ui logic should prevent you from being able to do anything with the note anyway.

Thanks for constant feedbacks on my approach! and Sorry for late replies, I have my college exams from 20 to 27 march + I also try to verify each point before commenting it here so it takes some time to form a perfect reply.

Got it! I will update my proposal with anything which was left before and update the diagram too.

Regarding all the things you have pointed out :

I think base level encryption is too risky in many ways... So how about moving the encryption and decryption to the very last point i.e, to the ui layer?

We can drop the ciphered_text field as mentioned and use a prefix + encrypted text format in the body. And since the encryption always start with JED01 we can check for :


if (note.body.includes('JOPLIN_CIPHER:JED01')) {

    // THIS IS A REAL ENCRYPTED NOTE! Lock the UI.

} else {

    // The user just typed "JOPLIN_CIPHER:" manually. Ignore it and render normally.

}

This will potentially solve the outdated vs updated app problem too and some potential conflicts as outdated app will just show the encrypted text...

There can be 2 cases further :-

  • If the user updated the body and remove the prefix + encrypted text, now when the data come backs to the new app the old data will be lost and overwritten by the new data.

  • If the user types something without removing the prefix + encrypted text, so now its like prefix + encrypted text + abcd or anything the app can check for the same note.body.includes('JOPLIN_CIPHER:JED01' if yes the app try to decrypt it but fails and throw an error which can be handeled by the UI this point mentioned in my proposal :

Graceful Degradation (Handling Vault Resets): If a user has previously reset their vault,
EncryptionService.decrypt() will fail due to a cryptographic mismatch on old notes. The
interceptor catches this specific error and replaces the standard Lock Screen with a read-only
state: "Decryption Failed: This note was locked with a previous, forgotten Vault password
and cannot be recovered."

Changing the error message to corrupted body etc etc....

  • If user has typed something between JOPLIN_CIPHER:JED01 like JOPLIN_CIPHER:abcdJED01 the note.body.includes('JOPLIN_CIPHER:JED01' fails and.. same thing will happen, decryption does not run and the gibberish ciphered text will be displayed in the screen.

If we use the UI encrypt decrypt, I don't think these would be needed at all?
If a note arrives wrapped in Cloud E2EE , our local JOPLIN_CIPHER:JED01 will be scrambled and hidden inside the outer ciphertext. The UI's .includes() check will naturally return false, completely bypassing our local decryption logic and allowing Joplin's E2EE placeholder UI (driven by the encryption_applied flag) to display safely until the background worker unpacks it.

So I actually have backtracked in my thoughts regarding the option gated Note save / load being an issue after having some discussion on the other candidate’s proposal. I’m still trying to figure out potential implementation concerns with conflict resolution, but this is what I wrote on the other thread GSoC 2026 Proposal Draft - Idea 7: Local Note Encryption - keshav0479 - #12 by mrjo118 :

For search indexing we can update this peice of code

private async doInitialNoteIndexing_() {
		const notes = await this.db().selectAll<NoteEntity>('SELECT id FROM notes WHERE is_conflict = 0 AND encryption_applied = 0 AND deleted_time = 0');

to not include the notes which are like 'JOPLIN_CIPHER:JED01%'?

For ui level encryption

I think the files which need to be intercepted will be NoteBodyViewer.tsx , NoteTextViewer.tsx and useRerenderHandler.ts..

  • NoteBodyViewer.tsx will be the place where main logic live, it will check if the note.noteBody includes the prefix and encrypted data. Additionally it will have a state called vaultPassword. If the note is locked but password is empty the ui fallbacks to a lockscreen with a password prompt. User enter the password and clicks submit and then the decryption take place using a useMemo hook... And then the decrypted data get passed to useRerenderHandler()

  • In useRerenderHandler() and NoteTextViewer.tsx we might just need small tweeks as a fail safe.

I think base level encryption is too risky in many ways... So how about moving the encryption and decryption to the very last point i.e, to the ui layer?

I posted another comment about 1 second before your last comment, in case you didn’t see. But my thoughts on your comment here is that option gated Note save / load utilised in the UI layer is already basically at the very last point, except its on the inside of the save / load method instead of on the outside. Putting the logic on the outside just means you will be doing a lot of code duplication

If we use the UI encrypt decrypt, I don't think these would be needed at all?
If a note arrives wrapped in Cloud E2EE , our local JOPLIN_CIPHER:JED01 will be scrambled and hidden inside the outer ciphertext. The UI's .includes() check will naturally return false, completely bypassing our local decryption logic and allowing Joplin's E2EE placeholder UI (driven by the encryption_applied flag) to display safely until the background worker unpacks it.

Note that if we transfer the local_cipher_text value to the body field on the server item with the JOPLIN_CIPHER: prefix, when E2EE is enabled, then double encryption presents an issue. If you upload the item to the server double encrypted, when you download item, it will only be decrypted by the EncryptionService after it is stored to the database, which means we cannot save the value directly to the local_cipher_text value in the db, otherwise the EncryptionService will not decrypt it.

In order to work around this, my suggestion would be for the case when is_locally_encrypted is true, in addition to adding the prefix to the body of the server item upon serialising, also skip encrypting the body value via the EncryptionService, so that the body (comprising of the prefix + local_encryption_cipher) is not double encrypted. Then when deserialising the body field of the server item, check if is_locally_encrypted is present on the body. If it is defined and set to true, or if it is undefined but the first 14 characters of the body match JOPLIN_CIPHER:, then set is_locally_encrypted to true on the deserialised item, set the body text after the first 14 characters to local_cipher_text, and set the body field to an empty string which has been encrypted by the EncryptionService (to avoid issues when decryption occurs). Then when the object reaches ItemClass.save in the synchronizer, it will be in exactly the state it needs to be. This should be the only logic required for preventing double encryption by E2EE, so no need for additional changes to Note save / load specifically for preventing it. Note that the purpose of having the is_locally_encrypted field on the server item (which should be a field which is excluded from encryption on serialise) is to avoid relying on the hacky prefix check once all clients have been updated

By what I have previously proposed is that we completely drop the local_cipher_text field and rely solely on the body, then here is how my proposed architecture natively handles E2EE:

  1. Local State: The database body holds JOPLIN_CIPHER:xxx.

  2. Sync Upload (E2EE ON): The Sync Engine calls standard Note.load(id) (without options gate). It grabs the body. The E2EE service encrypts it into JED01...(JOPLIN_CIPHER:xxx). It goes to the server.

  3. Sync Download: The Sync Engine downloads the item. It saves it. The DecryptionWorker kicks in, decrypts the E2EE layer, and restores the database body to JOPLIN_CIPHER:xxx.

  4. User View: The user clicks the note. NoteBodyViewer passes the {useLocalEncryption: true} gate to Note.load. Your logic decrypts the local layer, and the plaintext appears in RAM.

These part will still be the same even if we don't follow the ui encrypt / decrypt stratergy.

By what I have previously proposed is that we completely drop the local_cipher_text field and rely solely on the body,

Ok, I guess that would work, and your reasoning for not needing to do anything to work around the double encryption is correct in that case, but it does mean you additionally have to deal with bypassing the search indexing. On the other hand it means you don’t need to change the server item serialisation / deserialisation logic, other than to exclude the is_locally_encrypted field from the fields for encryption, which should still be included in the server item so that the prefix would only be used as a fallback.

I’d also say in this case that is_locally_encrypted should be nullable in the db so that it will have 3 states (I call it true / false / undefined for simplicity, but it would be stored as 1 / 0 / null in the db in practise).

I think an option gate on Note save / load which is enabled for UI flows is still a better option than putting logic in the UI code directly though, because as I mentioned it will save a lot of code duplication. In your case, the option gate would just add or remove the JOPLIN_CIPHER: prefix and encrypt / decrypt the body value, instead of transferring between a separate local_cipher_text field. And additionally in the load logic, where is_locally_encrypted is undefined / null, at that point it should read the prefix to determine if is_locally_encrypted should be true or false, which will then use the locked note UI when opening the note if applicable. Then is_locally_encrypted would get set to true / false on the next save, so that the prefix does not need to be checked for all note loads going forwards, to check if the note is encrypted, meaning you can skip doing a substring of the note body for notes which are not encrypted.

However, it does still mean the prefix will have to be added or removed from the cipher on every encrypt / decrypt for encrypted notes, which moves the logic so it is done on every save / load of a note rather than on every upload / download by the sync. To me it just feels like changing one impact for another, rather than reducing scope or risk.

What are your thoughts on this? i.e. my point about still using an option gate but without the local_cipher_text field, and your thoughts around the highlighted pros and cons of the solution with or without the local_cipher_text field?

I completely agree with this and I have already updated my proposal with this, By far the routes I think which will hold the { useLocalEncryption: true} option will be :
• NotePropertiesDialog.tsx
• useScheduleSaveCallbacks.ts
• useFormNote.ts
and the manual lock/unlock context command (e.g., contentActionLockNote)
Since, they are the main editor and reader of the note.

So, this means during Note.load, if is_locally_encrypted is undefined/null, we fall back to checking the prefix to determine the state and render the UI. Then, upon the next Note.save, we explicitly write 1 or 0 to the database. This allow us to rely strictly on the is_locally_encrypted flag for future loads?
This sounds fine to me and would improve performance too.

About this I think stripping and adding prefix on every load and save is still the best approach because doing it in every upload / download, might cause a bug sometime, Sync engine operates asynchronously in the background, often processing bulk note payloads. If a serialization/double-encryption bug occurs here, it risks silent data corruption across multiple devices.

Since, using the option flag we will be only processing one note per load/save :
I think String concatenation (adding the prefix on save) and substring slicing (passing the payload to the decrypter on load) are exceptionally cheap operations in JavaScript. Trading slightly heavier string manipulation on a single active note to guarantee 100% native compatibility with Joplin's E2EE Sync Engine is a highly favorable trade-off?

About the search indexing I think this sounds fine?

Note : Since, is_locally_encrypted will be set to 0/1/null, we can use the is_locally_encrypted flag instead of 'JOPLIN_CIPHER:JED01%'

EDIT : Additionally I want to ask that should we also pass vaultKey using option instead of allowing the model to access global state?

Ok, I’m happy for you to go ahead with your described approach.

If a serialization/double-encryption bug occurs here, it risks silent data corruption across multiple devices.

Regarding this, I still think the double encryption is easily handled with the other approach too, but I think what is more of a risk with both approaches is if you don’t include the option gate in all the right places, eg:

A particular example to watch out for is in Note.tsx there is some logic which reloads the note when editorNoteReloadTimeRequest has changed, so missing enabling the option gate for that route could result in corruption of the note, due to it not being decrypted on load

So you’ll need to make sure the note interactions by the user have the related code paths fully analysed and tested. The option gate will also need to be applied for the cli and api note interactions as well, not just in the UI code.

Thank you for the green light.

I completely agree with you that the biggest inherent risk of the Option-Gate approach is accidentally missing a route. You are spot-on about the editorNoteReloadTimeRequest edge case in Note.tsx if that bypasses decryption on a state change, a subsequent save would absolutely corrupt the ciphertext.

To ensure this risk is fully solved, I will look through the paths that will need the option gate to be passed and update the proposal with them if I found more. For now the path that I think will need the option flag has been mentioned in my new proposal :
AkshajRawatIdea7Proposal-V2.pdf (509 KB)

I have updated the top of this post with my new proposal.