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

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