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

@akshajrawat Just wanted to update you with my list of considerations and further analysis.

Things already covered in the proposal without further explanation required

  • is_locally_encrypted flag is added to notes and server items
  • Option gated Note save / load is used for the note editor and viewer flows of the desktop and mobile apps, and the load / save note flows of the cli and api, in order to only apply local encryption / decryption for the paths which need it and to bypass sending unencrypted data via the sync during editing. Care is taken to cover the full flow, to avoid possible corruption
  • An unlock note UI will be added to allow decrypting notes for viewing and editing
  • A single encryption key will be used for managing encrypted notes, and a management UI will be implemented with change password and reset password options
  • Encrypted notebooks will be implemented using a cascading lock / unlock all notes in notebook function (which should be protected against double encryption / decryption)
  • For encrypted resources, they are decrypted to a temp-cache folder, and this is emptied on app start and exit on desktop, but just on app start on mobile, which is fine because the data dir for the app is secure. Decrypted resources should not get read completely into memory
  • Encrypted notes will be marked with a padlock icon in the note list for visibility

Conflict resolution concerns

Regarding concerns around conflict resolution which I said I’d look into, my concern was that storing the encrypted note content in a separate field would mean that the value would be hidden from any kind of conflict resolution, making it impossible to resolve it in any meaningful way. As you moved to an approach which does not introduce a new field, this however is a non issue. While the content is still in encrypted form, it wont be legible to compare differences, but as long as the user is able to choose one version, or both, then the contents can at least be verified by unlocking the notes from the original notebook later (so no change required). Other considerations I think are out of scope for this project, but instead are factors to consider for the automatic conflict resolution project instead.

Just one change should be considered here, which is that the unlock note UI should not conflict with any UI which will be added by the automatic conflict resolution proposal, but the conflict UI should take precedence. If that proposal goes ahead, it might be worth considering just suppressing the unlock note UI completely for notes within the conflicts folder on the desktop and mobile app.

Search indexing (no change to the solution required)

Ideally encrypted note content should not be included in search indexing as it has no use, so opting out for notes with is_locally_encrypted true is a good idea. For old clients receiving encrypted notes from the sync, the opt out wont be possible. However, the note_normalized table is cleared on app start, so after fallback logic has migrated the note to have is_locally_encrypted set, this would no longer be an issue

Revision management

Initially I proposed that encrypted notes should opt out of the revision service. This however I think is risky, particularly when you consider the possibility of conflicts and a very poor experience to be able to resolve them. Additionally, I discovered there is an edge case whereby it is possible for an unencrypted revision to be uploaded by the sync, after a note has been been encrypted on another device, and without further handling, this unencrypted revision will be available on all devices until the the revision expires.

Aside from additional impact, the main concern with storing revisions for encrypted notes is that when content is updated, updated ciphers will create diffs which are as big as the entire note. Upon reflection, increased file size for revisions seems like an acceptable compromise, but the main issue is that in order to evaluate a revision for such notes, the diffs of the entire chain of revisions must be evaluated, which could perform poorly or crash the app, for long chains of revisions. My proposal would be for encrypted notes, instead of opting out of the revision service, the revision service should create revisions which have no parent, which means evaluation of those revisions is standalone.

Therefore a concept of encrypted revisions should be introduced, by adding an is_locally_encrypted flag which matches the value on the note at the time. In order to solve the sync edge case, revisions for an encrypted note where is_locally_encrypted is not true could be deleted upon opening that note in the UI of the desktop or mobile apps, or upon executing get note in the cli / api. This is not perfect, as unencrypted revisions can be persisted locally indefinitely, but as it is an edge case, this should be acceptable. Particularly for the UI routes, this would prevent ever being able to view such revisions, because the note must be opened first to get to the revision viewer. Regarding backwards compatibility, encrypted notes which were modified on old clients would have their history removed even if it was still encrypted, but this is an acceptable compromise because it is also edge case.

Additionally, to make the revisions for encrypted notes useful, it would be necessary to show the unlock note UI in the revision viewer where is_locally_encrypted is true. It wont be necessary to do any backwards compatibility checks here (see backwards compatibility section for migration scenario).

Backwards compatibility

Storing a JOPLIN_CIPHER: prefix in the note body for encrypted notes (stored in the server item and in the db, only removed via option gated Note load in order to decrypt the value), ensures that the encryption status cannot be lost when syncing encrypted notes with old Joplin clients. It also prevents possible double encryption via E2EE, as it makes it possible to determine a cipher created via local encryption specifically.

Regarding downloading server items which have passed through an older Joplin clients, the is_locally_encrypted flag will be stripped off, and it is necessary to update the is_locally_encrypted flag to true again for the purpose of search indexing and revision management (also the case when an old client containing encrypted notes synced from a newer client is upgraded). As it is not possible to determine the prefix on save when incoming from the sync, due to E2EE, the same solution for the revision management can be applied. Upon opening any note, where is_locally_encrypted is not defined, a migration will be performed which will set the value to true or false depending on whether the prefix is set

Resource management (additional questions / considerations)

  • There is a resource management screen now on both the desktop and mobile apps (to be released on mobile). How would you handle when an encrypted resource is opened from that screen?
  • How would you handle when a note with a resource is encrypted, and that resource is used in other notes which are not encrypted?
  • If you remove the reference to the encrypted resource in an encrypted note, how would you manage detecting the need for decryption of that resource in an efficient way which doesn’t involve scanning notes? What if the resource is used in other encrypted notes still?
  • Do we need to include an is_locally_encrypted flag for resources too, and have dedicated management to lock / unlock encrypted resources?

Got it!

understood! I'll update the proposal with this change.

Regarding these, here is how the architecture handles those specific edge cases:

1. Opening an encrypted resource from the Resource Management screen: I think clicking a locked file from the global Resource Screen should trigger the exact same Vault Password prompt and temp-cache streaming pipeline used inside the note editor.

2. Handling a resource shared between an encrypted note and an unencrypted note: If a resource is shared, encrypting it can break the unencrypted note. To solve this safely, when a note is locked, the system will check the note_resources table. If a resource has multiple associations, the system will duplicate the resource. The locked note gets the new encrypted copy, and the unencrypted note retains the plaintext original.
For text editor still pointing toward the unencrypted resource we can run a quick swap :

note.body = note.body.replace(":/123", ":/456");

Edit : Also I think there can be a query that what if the image is in too many notes will the app keep making duplicates?. Since, true shared-ID resources are a rare edge case in Joplin as even if i drop same image in 2 note in joplin it creates 2 complete different images in the resource dir, so I think we can use this duplication method without any performance issue..

3. Detecting decryption needs if a resource reference is removed: I think we can rely strictly on Joplin's existing note_resources mapping table and orphan cleanup logic. If an encrypted resource is deleted from one note but is still referenced by another locked note, the mapping table protects it. The encrypted file is only removed from disk by the standard cleanup job once its reference count reaches zero. (and if the resource is referenced in another unencrypted note the problem is solved by point 2 above)

4. Adding an is_locally_encrypted flag to resources: Yes, adding this flag to the resources table is the cleanest approach. It allows the global Resource Management screen to efficiently display a padlock icon without needing to scan file contents. However, I think we should avoid "dedicated manual management" (manually locking/unlocking files from the global screen). A resource's encryption state should strictly depend on the note it is attached to.

Isn’t note_resources populated via a background service? How often does that population get triggered?

Depending on those answers, can we really rely on those values to determine whether encrypt / decrypt / copy of a resource should happen?

Regarding the idea to copy resources used in other notes when encrypted, this could create orphaned duplicate resources if you unencrypt those notes, unless you handle clean up too.

Also, bear in mind that note_resources I don’t think considers revisions. So there needs to be handling for viewing encrypted / unencrypted revisions which have encrypted / unencrypted resources in the revision viewers.

And additionally, you need to ensure that resources in encrypted notes do not get automatically deleted. See comment from one of the other proposals:

You are right, note_resource is populated a every 5min using the Syncronizer service in packages/lib/Synchronizer.ts :

try {
	if (this.resourceService()) {
	logger.info('Indexing resources...');
	await this.resourceService().indexNoteResources();
        }
} catch (error) {
	logger.error('Error indexing resources:', error);
}

Yes, we can still rely on those values but with a little more hybrid approach, every step remain same instead of this :

  • we cannot rely on the database to get active note resource data (as it might not be 5min yet), though we can just read the note content to get the resourceId instead, using this function in Note.ts :
public static linkedItemIds(body: string): string[] {
	if (!body || body.length <= 32) return [];
	const links = urlUtils.extractResourceUrls(body);
	const itemIds = links.map((l: any) => l.itemId);
	return unique(itemIds);
}

So, now we have all the linked resources Id. Now, just query the database to find if there is any associated unencrypted notes with any of the resource Id? If yes, Duplicate and assign the new encrypted note with the new duplicated encrypted resource.

This similar cycle can be ran when setting a note to not-locked too, so this logic goes both ways.

If you mean that the revision will only have the ![Screenshot 2026-03-23 051841.png](:/7c2e599103874ac799eab59ee6118c07) and when we decrypt it to view the revision, how would the app know what to display? :

  • I don't think we need any extra peice of code here because the decryption of note is already done in the core Resource.ts, the revision service will eventually use this core to decrypt the data. Both NoteRevisionViewer.tsx and standard note editor uses /renderer/MdToHtml.ts as a centralized renderer as seen in this peice of code in NoteRevisionViewer.tsx :
const result = await markupToHtml(markupLanguage, noteBody, {....})

That's a nice catch by him.. I looked through that peice of code and I think its quite easy to supress this as its already suppressed for standerd E2EE :

if (note.encryption_applied) {					
		foundNoteWithEncryption = true;
		break;
}

However, if the background robot permanently skips locked notes, the database will never know what resources belong to it! To solve this, we must map the resources manually. Inside the Note.save Option-Gate, right before the plaintext is converted to the AES-GCM cipher, the interceptor will parse the plaintext payload (using the linkedItemIds function mentioned earlier) and synchronously invoke setAssociatedResources to populate the database.

Regarding this point, I don't think they are actually orphaned..
Like suppose NoteA and NoteB uses ImageA.
NoteB gets locked so we create ImageB and assign it to the locked note.
Now, NoteB gets unencrypted.. So, ImageB also get unencrypted and is actively still used by NoteB and won't trigger the duplication logic again if locked because now the resource are unique.

And joplin also duplicate resource for each note as said in this point :

So, at last we just satify the normal behavior of the app.

Yes, we can still rely on those values but with a little more hybrid approach, every step remain same instead of this :

  • we cannot rely on the database to get active note resource data (as it might not be 5min yet), though we can just read the note content to get the resourceId instead, using this function in Note.ts :

I don’t quite understand what you’re saying. Are you suggesting that when making a change to an encrypted note, you will add the resources to note_resource table? That wouldn’t work, because if you then sync the note to another client, the note_resources table on that client will not have recognition of the included resources, and never will unless you modify the note on that client

I don't think we need any extra peice of code here because the decryption of note is already done in the core Resource.ts, the revision service will eventually use this core to decrypt the data.

Your solution has to be completely rock solid to not allow any way for an encrypted resource to be in an unencrypted note, otherwise the resource will be inaccessible in notes and revisions where this is the case, without any password prompt implemented for the scenario

Regarding this point, I don't think they are actually orphaned

Ok they are not orphans, but its not ideal to be making duplicates which don’t get reverted if you later decrypt them

You are completely right. Because note_resources is a locally made table, relying purely on a local database update can breaks cross-client synchronization. If Client A syncs the note, Client B's indexer would receive a completely unreadable ciphertext payload, will fail to extract the links, and never map the resources locally in the note_resources table.

The Fix: Plaintext Manifest Header : To solve this globally without breaking the "payload in the body field" architecture, the encryption Option-Gate (Note.save) will extract the resource IDs from RAM (using Note.linkedItemIds()) and inject them into a plaintext header prefixed to the cipher string.
Something like this :

JOPLIN_CIPHER:MANIFEST(id1,id2,...):[AES_256_GCM_PAYLOAD]
  • Cross-Client Sync: When Client B downloads the synced note, its ResourceService.indexNoteResources() is updated to recognize the JOPLIN_CIPHER: prefix. Instead of skipping the note entirely, it extracts the IDs from the plaintext MANIFEST(...) block and safely passes them to setAssociatedResources. This guarantees perfect resource mapping on all synchronized devices without ever decrypting the payload on a background thread.

  • For older app clients the app will simply display the ciphered text in the body as usual.

  • The adding and getting info from MANIFEST part will be potentially done only in these files Note.ts / ResourceService.ts / EncryptionService.ts and we will use regex to make it performative.

This is a good point. This highlights the exact edge case of a user manually copying an encrypted markdown resource link (e.g., ![image](:/encrypted_id)) and pasting it into an unencrypted note. You are absolutely right that this bypasses the note-level password prompt, which could lead to a broken rendering state or application errors.

The Fix: Resource Server Firewall & Graceful Degradation To make this rock solid without introducing out-of-context password prompts on public notes, the architecture relies on the internal resource server as an isolation firewall. Because all resource GET requests route through this internal server. If an unencrypted note (or revision) attempts to render an encrypted resource ID, the server intercepts the request and evaluates the current vault state:

  1. If the Vault is Unlocked (Key in RAM): The server securely stream-decrypts the file and serves the image.
  2. If the Vault is Locked (No Key in RAM): The server catches the unauthorized access attempt. Instead of throwing an error or breaking the markdown parser, it utilizes graceful degradation. It intercepts the stream and returns a pre-packaged "Vault Locked" SVG/PNG placeholder image (e.g., a padlock icon). This guarantees the renderer never crashes.

Understood. We can achieve a clean reversion without requiring any SQLite schema migrations (avoiding adding new metadata fields).

The Fix: Clean Reversion Lifecycle via Manifest Mapping We simply extend the Plaintext Manifest established above to act as a reversion dictionary. During the duplication phase, instead of the manifest just listing the new resource IDs, it will map the newly generated encrypted ID back to its original plaintext ID:
Format:

 JOPLIN_CIPHER:MANIFEST(new_id_1 : original_id_1):[AES_PAYLOAD]

When a user explicitly triggers an "Unlock Note" action, the lifecycle will execute a reversion check:

  1. The system reads the MANIFEST header to find the original ID mapping.
  2. The system checks if that original plaintext resource still exists on the disk.
  3. If it does, the system runs a string replacement on the note's Markdown body, pointing the link back to the original plaintext resource ID.
  4. The temporary duplicate resource is then instantly and cleanly purged from the file system.

Backward Compatibility Testing :

  • Acceptable Degradation: If a user views a public note containing an encrypted link on an older client (which lacks the new Server Firewall), it simply falls back to Joplin's standard "Broken Image / File Not Found" icon without crashing the application.

If all of this sounds too complex we can anytime fallback to a more simpler logic by displaying placeholders etc in place of encrypted resources confilct instead of handling them, but i believe these approach will eventually make the UX more better for the user and can be done within 350hr scope if a good proposal map is present beforehand.

Adding this for reference, but superceded by the latter part of my reply

and we will use regex to make it performative.

I think the MANIFEST part would need to be mandatory even if there are no ids, to ensure efficiency for scanning purposes, but it is going to mean you can’t remove the prefix by a fixed length anymore for every Note load, although the fallback detection logic of encrypted notes can still check by a fixed length substring, which is where the performance matters more. So those cases are probably ok.

What I’m more concerned about though is scanning the resources contained in the note on every save. As the note editors auto save as you type, this could slow down the save operation on large notes. You should make sure the call to scan and update the note_resources is fire and forget, rather than waiting for it. I don’t think its necessary to queue those changes as the calls could build up due to the slowness of it, and you’ve already proposed a contingency if encrypted attachments end up being used in unencrypted notes, and proposed a means to view encrypted attachments from the attachment management screen.

JOPLIN_CIPHER:MANIFEST(new_id_1 : original_id_1):[AES_PAYLOAD]

Best to use a different separator than a colon within the manifest, so you can use the ending colon as an anchor for the regex.

Further thoughs

Taking step back, if you make a copy of a resource and encrypt the copy, isn’t that defeating the purpose of encrypting a resource, as the encrypted resource is still publically available? I think it would be simpler and make more sense to simply add a visual indicator on embedded resources and resource links which are not encrypted within an encrypted note (in a similar way to showing a vault locked placeholder image for an encrypted resource in an unencrypted note).

So when you encrypt a note, scan the resources in the body and for any which are found on note_resources, do not encrypt those resources and display a warning that some resources were not encrypted because they are used in other notes, please upload a copy of this resources if you wish to encrypt them. Lets say we ditch the MANIFEST. In terms of if an encrypted resource is used in other encrypted notes, those references wont be in note_resources, but we don’t care, as the is_locally_encrypted flag will prevent against double encryption. On decryption of a note, if we decrypt a resource used in other encrypted notes, the same concept applies. If it is publically available in any note, there is no reason to care about it being public in other encrypted notes. In those notes, you would just see the indicator that the resource is not encrypted when you open the note. To prevent confusion for users though, when decrypting any note which contains at least 1 resource, you could present prompt (with the option to cancel) which warns that all contained resources will be decrypted, and if shared in other notes, they will be decrypted in those notes also.

The other situation to consider is when you add or remove resource references to notes without toggling encryption on the note, but where the resources have various encryption states. Just having a indicator for the encrypted state of the attachments when it doesn’t match the state of the note should be sufficient. The only area of concern here is if you remove the only reference of an encrypted resource in an encrypted note (or other edge cases where note_resources has not yet been populated for uses of a resource), then there isn’t a specific UI available to decrypt it. I guess in this case though, the user could just download the resource from attachment management and reupload it, or more likely they don’t need to resource anymore and want to delete it, which they can do from attachment management anyway.

The only thing you need to be careful with is to make sure that you do not double encrypt or double decrypt resources with the local encryption. This will be controlled by the is_locally_encrypted flag on the resource. However you also need to include fallback logic to detect if the resource is encrypted upon selecting the resource for encryption / decryption in the encrypt / decrypt note flow and for rendering / loading resources in all cases, so use an option gated Resource load / save for view and edit of notes on desktop, mobile, cli and api. This would involve using a JOPLIN_CIPHER prefix when encrypted, in the same way as the note body. Even though I don’t think you can update a resource within Joplin, functions which allow reuploading data could still strip the is_locally_encrypted flag from the server item, if used on an old version of the client. So where an option gated Resource load / save is used, if is_locally_encrypted is undefined, check for the prefix update the is_locally_encrypted flag accordingly. For better optimisation, if the resource is not encrypted, update the is_locally_encrypted flag to false in the same save call used to save the encrypted content.

In summary, the proposed solution for resource management

  • Encrypted resources should be viewable with a password prompt on resource management on desktop and mobile, and there should be a visual indicator shown on the list item for encrypted attachments
  • An is_locally_encrypted column should be added to the resources table and the server item
  • Option gated Resource save / load should be used to encrypt / decrypt embedded and linked resources via the firewall. This logic should include adding and removing of a JOPLIN_CIPHER: prefix on encrypted content, and include migration logic where is_locally_encrypted is undefined
  • Resources embedded or linked in notes should have a visual indicator when the encryption state does not match that of the note. Encrypted resources in an unencrypted note do not need to be readable, but should indicate that they are locked. Additionally, consider if encrypted resources should always have an encrypted indicator for user peace of mind, due to the possible mixed states
  • When encrypting a note, scan the resources in the body, and where a resource is included in other notes, do not encrypt it and present a warning in this scenario
  • When decrypting a note containing at least 1 resource, present a warning prompt about the resources being decrypted in other notes if shared, with the option to cancel

Additionally, it is worth considering adding a confirm prompt (which is always shown) for the encrypt / decrypt all in notebook functions, as the cascading encrypt / decrypt would have an affect on mixed resource encryption states as well

UPDATE: In order to cater for possible edge cases where resource usages have not yet been scanned, for a resource in a note which is about to be encrypted, the following can be done to allow decrypting encrypted resources appearing in unencrypted notes:

For encrypted resource in an unencrypted note, show a locked placeholder in place of the embedded resource or resource link. When you tap this placeholder, it will prompt ‘This resource is locked, do you want to permanently remove encryption for this resource in all locations?’. If choosing yes, upon entering the password it will decrypt the note and save it in its unencrypted state.

UPDATE 2: While the resource management solution no longer involves calling setAssociatedResources directly, you do need to ensure the service which cleans unused resources will exclude clean up of resources where is_locally_encrypted is true. This means that orphaned encrypted resources will have to be deleted manually, but I think that is an ok compromise to avoid the complexity required to associate them.

An optimal way to do this could be to alter the NoteResources.orphanResources function to join to the resources table and exclude encrypted resources there. Please include details about this in the “Resource Handling, Mixed-State UI, & Option-Gated Resources” section.

To confirm we are 100% aligned, here is the updated summary of the resource management architecture I am writing into my final proposal:

Quick Summary:

1. Database & State Management

  • Add the is_locally_encrypted boolean column to the resources table and server item.
  • Implement migration/fallback logic: Use an Option-gated Resource load/save that checks for the JOPLIN_CIPHER: prefix if is_locally_encrypted is undefined (handling old client syncs without double-encrypting).

2. The Encryption Flow & User Prompts

  • Encrypting: Scan note_resources. If an embedded resource is shared with other notes, do not encrypt it. Display a warning: "Some resources were not encrypted because they are used in other notes."
  • Decrypting: When decrypting a note containing resources, present a warning prompt that these resources will also be decrypted in any other notes they are shared with.
  • Bulk Actions: Add strict confirmation prompts for Notebook-level "Encrypt/Decrypt All" actions due to the cascading effect on resources.

3. Mixed-State UI & Firewall

  • In-Note Indicators: Add visual indicators on resource links when their encryption state doesn't match the note (e.g., an unencrypted image in a locked note, or a locked placeholder in a public note).
  • Option-gated Firewall: Use the internal resource server to intercept rendering. If an encrypted resource is loaded, it requires the vault state to be unlocked, otherwise it degrades to a locked placeholder.
  • Attachment Management: Ensure encrypted resources show a lock indicator in the list view, and prompt for a password when the user attempts to view/manage them directly.

Two edge cases I plan to address :

  • The Indexer Race Condition: Because note_resources is populated asynchronously by the background indexer, if a user pastes a resource and immediately clicks "Lock," the table might not be updated yet. . To prevent accidental encryption of shared resources (or missing new ones), the lock command will explicitly force a synchronous Note.save() from the editor state, await the NoteResource indexer to update the mapping table, and then evaluate the shared state.

  • Mobile vs. Desktop Firewalling: While intercepting the internal HTTP resource server (joplin-content://) works perfectly for the Electron Desktop app via handleCustomProtocols.ts, I was reading packages/renderer/MdToHtml/renderMedia.ts and noticed that the Mobile app resolves local resources directly to file:// URIs inside the NoteBodyViewer WebView, bypassing a local server fetch entirely. Furthermore, because the renderer outputs static HTML strings, we cannot utilize a custom React Native component to gate the content. To maintain the "Firewall" on mobile without breaking the HTML DOM, I will inject the Option-gate directly into the Markdown Resource Path Resolver (renderMedia.ts):

    1. If the vault is locked, the resolver overrides the path to output a safe file:// URI pointing to a static Padlock placeholder SVG (and downgrades <video>/<object> tags to <img> tags to prevent renderer crashes).

    2. If the vault is unlocked, it outputs the file:// URI pointing to the decrypted chunk in the isolated temp-cache folder.

The only thing I disagree with is this:

The Indexer Race Condition: Because note_resources is populated asynchronously by the background indexer, if a user pastes a resource and immediately clicks "Lock," the table might not be updated yet. . To prevent accidental encryption of shared resources (or missing new ones), the lock command will explicitly force a synchronous Note.save() from the editor state, await the NoteResource indexer to update the mapping table, and then evaluate the shared state.

I was originally proposing to not handle this edge case at all. The visual indicator of unencrypted resources in unencrypted notes makes such cases determinable, even if some manual process is required to decrypt the resource.

I’d rather avoid doing a resource scan in save, especially if you have wait for it to be able to encrypt the note. It seems too excessive to handle an edge case.

Also the reproduction steps you specified for this edge case, are not correct. To reproduce, you would have to attach a resource to one note, then switch to another note, paste in the same resource reference and lock the note within the space of 5 minutes. Alternatively, you can reproduce it by downloading a new resource via the sync, and locking one of multiple notes using that resource within 5 minutes of it being downloaded.

However, you could deal with this scenario more simply in a user friendly way. You already have logic to show a locked placeholder for an encrypted resource within an unencrypted note. You could extend this so that when you tap this placeholder, it will prompt ‘This resource is locked, do you want to permanently remove encryption for this resource in all locations?’. If choosing yes, upon entering the password it will decrypt the note and save it in its unencrypted state.

Thanks for feedack!
I have updated my main proposal post with these final architectural refinements:

  • Lightweight Save Path: Removed the note_resources scan during Note.save. The save action is now strictly limited to applying the AES envelope and suppressing beforeChangeItemJson to prevent database bloat.

  • Lazy Revision Cleanup: Moved the plaintext revision cleanup into the Option-Gated Note.load path. Doing this on-load cleanly handles both the initial lock transition and any late-arriving plaintext revisions from cross-device syncs.

  • Resource State & UI: Confirmed we are sticking with the is_locally_encrypted flag (no new readyStatus). The primary conflict resolution for unindexed shared resources now relies entirely on the Interactive Placeholder (1-click global decryption from the UI).

The master proposal in the original post is fully updated.
AkshajRawatIdea7Proposal-V3.pdf (512.3 KB)

Some comments:

Used for notes synced from older clients. Triggers a fallback
.startsWith() string check, which is then "healed" (resolved to 1 or 0) upon the user's
next save operation, ensuring that future loads are high-performance and rely
strictly on the database index.

This part is now outdated. Rather than healing on save, a migration is performed on load. And please be explicit that the prefix check will only occur if the is_locally_encrypted value is null / undefined.

Also mention that this decision is a compromise in order to get around E2E decryption being a background process, and it means it is possible for unencrypted revisions to exist locally in the database for certain edge cases if the user does not open the note, but the solution ensures that such revisions can never be viewed from the Joplin UI

Vault Reset (Graceful Degradation): If a user forgets their password, a complete vault
reset generates a new Master Key. Because old locked notes can no longer be decrypted, the
Note.load() interceptor is designed to catch the resulting cryptographic mismatch error and
degrade the UI gracefully, displaying a read-only "Decryption Failed: Invalid Vault Key"
screen instead of crashing

You should mention that the master key is replaced, not only that a new key is generated. Because there is no reference stored for which key is used

Lock-Vault Path (UI Active): If the option gate is passed, the interceptor converts the
plaintext to the JOPLIN_CIPHER:JED01... string before the SQLite transaction occurs. Any
transient UI flags are stripped out.

You didn’t explicitly mention that the content will be encrypted here. Please specify this and that the prefix is added on after.

Also what does it mean when you say any transient UI flags will be stripped out?

It solely applies the AES-GCM envelope and forces beforeChangeItemJson = null to
prevent item_changes table bloat

The purpose of this isn’t to prevent UI bloat, it’s because beforeChangeItemJson will cache the unencrypted note body at the point you encrypt the note, which may create a revision with that old content, depending on how long the most recent edit to the note was prior to this. FYI whenever saving an item change for a note, the previous record for the note is deleted, so no such bloat occurs

Preventing item_changes Leaks: Because this architecture utilizes the native body field for the
cipher, standard saves would generate AES-GCM string diffs in the item_changes table, causing rapid
database bloat. To prevent this, the Option-Gate explicitly intercepts the oldNote diffing logic and
forces beforeChangeItemJson = null when saving a locked note, bypassing the bloat entirely.

Please remove this section. You already repeat the implementation details in the “VII. Ecosystem & Headless Integration” section. Make sure to also amend the that section to reflect my previous comment about the purpose of forcing beforeChangeItem to be null, and it is that section you can explain in a bit more detail of the reasoning

Revision History Protection & Cross-Device Sync

In this section, you also need to mention how the is_locally_encrypted value will be populated on revisions. When saving a revision, you just need to copy the is_locally_encrypted value from the note

The Object Firewall (Option-Gated Lifecycle)

In this section, there is an important clarification I think needs to be mentioned. You should specify that useLocalEncryption via {useLocalEncryption: true, vaultKey} will be set to true regardless of whether or not the note is encrypted, because it is a means to determine paths where the encryption interceptor ‘may’ be required, depending on the encryption state of the note.

Additionally, I don’t think vaultKey should be included in the gated option json. You have already removed it from the underlying db tables due to the single key approach, so there is no need to include it in the option fields.

On Note.load(id, options) Decryption is handled via a Just-In-Time (JIT) RAM Pipeline

In this section, you did not mention that the prefix is removed before decrypting it. Also in both this section and the Note.save section, please mention that the adding / stripping of the prefix and the encryption / decryption of the remaining string, will only occur when is_locally_encrypted is set to true initially or via the migration logic

Targeted UI Hook Injection

In this section, please mention which files cover both the desktop and mobile routes

Recursive Bulk Encryption (Replacing Folder-Level Locks)

It’s important that this function is protected against double encryption / decryption, eg. if you encrypt a notebook where one of the notes within it is already encrypted. The check to prevent this based on the is_locally_encrypted column can be built into global encryptNote / decryptNote functions, which will be used for the UI encryption toggle for individual notes as well. But note that this logic must require that is_locally_encrypted is not null (otherwise throw an error), which means there is a prerequisite to perform an option gated Note.load for each note, before attempting encryption / decryption, so that the migration logic which ensures is_locally_encrypted is populated at this point. Throwing an error if it is not populated would be a belt an braces validation for an unexpected scenario.

Additionally, there should be some kind of confirmation prompt for both the bulk encrypt and decrypt actions, as it makes a significant change if the notebook contains many notes. You can also take the opportunity in this prompt to explain the effect this will have on resources also.

Self-Healing Migration: To support older Joplin clients that lack the new schema,
an undefined is_locally_encrypted flag triggers a fallback stream-check for the
JOPLIN_CIPHER: prefix. The system then corrects the database by updating the
is_locally_encrypted flag (optimizing plaintext checks by resolving them to false
automatically during standard saves)

Please mention explicitly that this will happen on resource load, not on resource save. Also please clarify or remove the last part about standard saves

SELECT id FROM notes WHERE is_conflict = 0 AND encryption_applied = 0 AND
deleted_time = 0 AND (is_locally_encrypted = 0 OR (is_locally_encrypted IS NULL AND
body NOT LIKE 'JOPLIN_CIPHER:%'))

Please remove the prefix check. Only checking the is_locally_encrypted column is necessary

When E2EE is enabled, the Sync Engine treats the locally-encrypted
body as standard text, securely wrapping it in a secondary E2EE envelope for transit,
requiring zero modifications to Joplin's Synchronizer.ts

I think it’s worth mentioning that the is_locally_encrypted field will to be excluded from encryption in the server item serialisation / deserialisation, but the actual synchronizer logic won’t change

Mixed-State UX & Conflict Resolution:

You’re missing some details in this section:

  • You haven’t mentioned anything about including a visual indicator on resources within encrypted notes. Ideally this should be for both encrypted and non encrypted resources (for better indication to give user peace of mind), but as a minimum it should indicate when an unencrypted resource is within an encrypted note, to make it clear to the user it is not protected
  • You have not implemented this in your proposal: “When encrypting a note, scan the resources in the body, and where a resource is included in other notes, do not encrypt it and present a warning in this scenario”. This check would be purely based on presence within the note_resources table via the appropriate existing function (without awaiting for processes to run which populate it). Race conditions do not need to be considered here. Additionally, you need to prevent double encryption here by checking the is_locally_encrypted flag on the included resources, in addition to suppressing resources in use. You should also explain the reasoning here that it would be pointless to duplicate the resource in this scenario, because the original would still be available on the user’s data in an unencrypted state
  • For the “Unlocking a note alerts users to shared states“ section, it is already required to scan for resources within the note upon unlocking a note, in order that they will be decrypted if necessary. Therefore the warning should only be displayed if the note contains at least one resource. Additionally, you need to prevent double decryption here by checking the is_locally_encrypted flag on the included resources
  • In the “Global Attachment Management” section, please specify that this applies to the attachment management screen on both desktop and mobile

Schema Update: Implement the 3-state nullable is_locally_encrypted cache flag across the
notes, revisions, and resources tables/server items, including the JOPLIN_CIPHER: legacy
fallback migration logic.

Please clarify that legacy cipher migration does not apply for the revisions table

Other comments:

  • Please replace all references of ‘JOPLIN_CIPHER:JED01’ with just ‘JOPLIN_CIPHER:’ in your proposal. JED01 is an internal implementation detail of the existing E2EE which the existing encrypt / decrypt functions will deal with. Therefore, all of the prefix handling implemented in your solution should not include JED01 in any checks

Thank you so much for the incredibly detailed review
I have gone through your feedback line-by-line and updated the master proposal to V4.

Originally, I was referring to temporary state variables (like an is_local_session_unlocked boolean) that the React frontend might temporarily attach to the note object just to handle the lock-screen rendering logic. I wanted to note that the interceptor would strip these out so they wouldn't accidentally be written to the SQLite database.

However, realizing that this is a minor implementation detail and that Joplin's standard BaseModel.save sanitization handles invalid columns anyways, it just added unnecessary confusion. I have completely removed that sentence from the V4 proposal.

Key Updates & Clarifications in V4:

  • Visual Architecture Added: To make the data flow instantly clear, I have added comprehensive system topology and flowchart diagrams. These visualize exactly how the Option-Gate Firewall strictly isolates the plaintext (in RAM) from the background ecosystem (on Disk) without requiring complex background logic.

  • The old Client "Compromise": I explicitly clarified the null state migration on load. I added the explanation that while bypassing background E2EE decryption means unencrypted revisions might technically exist in the database under rare edge cases, the system guarantees they will never be rendered in the Joplin UI.

  • Shared Resource Duplication: Added the specific reasoning for why we skip encrypting shared resources (duplicating them is pointless since the original remains available in the user's data in an unencrypted state anyway).

  • Additional changes:

    • Purged all internal references to JED01 in the prefix checks.

    • Corrected the beforeChangeItemJson explanation (explicitly noting its purpose is to prevent plaintext caching/leaks, not database bloat).

    • Ensured bulk-encryption actions now have a pre-load prerequisite to guarantee the is_locally_encrypted flag is populated before evaluation.

A quick note on the overall design : By moving the plaintext revision cleanup to the gated Note.load path and relying on Interactive Placeholders for unindexed resources, the architecture successfully avoids forcing heavy, synchronous database scans during the editor's auto-save loop. The priority remains absolute UI performance and strict data isolation.

The final V4 proposal is attached below.
AkshajRawatIdea7Proposal-V4.pdf (860.7 KB)

I would just like to say well done for writing a detailed and well thought out proposal for this project, and thinking about all the feedback rather than blindly applying it. It has turned out to be a complex project!

I just have a few minor corrections to suggest, but overall the proposal now includes everything which we have discussed. Bear in mind I have not re-read the whole document to re-review it though, so if you have made any additional or structural changes, review for those may be missed.

item_changes Leak Prevention: The beforeChangeItemJson field in Note.save
will be forced to null whenever is_locally_encrypted is 1, preventing the massive
cipher string from bloating the item_changes table via the oldNote logic.

In section VII you still have this section about bloat. This is superseded by the last bullet point in the section.

Also, you still mention of vaultKey in the '{ useLocalEncryption: true, vaultKey }' json in a couple of places in the object firewall section

Lazy Revision Cleanup (Sync Leak Prevention): To handle plaintext revisions from the
initial lock action or late arrivals via cross-device sync, a "lazy deletion" occurs here. On every
load of an encrypted note, the interceptor automatically deletes any revisions for that note
where the encrypted flag isn't set.

Please make it clear that this and the previous bullet point only occur for the Lock-Vault Path, in the same way as you did in the Note.save section where you described the default path as well.

Additionally, for better readability, please move the null (unknown) explaination of migration logic in the 3-state performance cache section into the Note.save section (reduce it just a summary in the original section instead) and mention that this logic applies in all places where useLocalEncryption = true, before the logic which checks whether is_locally_encrypted is true.

Also, one very important correction: lazy revision cleanup must only happen when is_locally_encrypted is set to true on the note (initially or via migration)

Targeted UI Hook Injection
In this section, please mention which files cover both the desktop and mobile routes

This point has not been addressed

Final Polish: Clean up any UI edge cases and ensure AES-GCM integrity checks handle
corrupted sync conflicts gracefully

I didn't mention it before, but now think it's worth adding an explicit note here in phase 4, so it doesn’t get forgotten. It should be verified (on desktop on mobile) that the unlock note UI does not take priority over any new conflicts UI being implemented for the conflict resolution GSoC project, where the note is in the conflicts folder. If necessary, the conflicts UI should explicitly be excluded for notes which are within the conflicts notebook.

EDIT: Actually there is one more thing which is not addressed, which got lost in our previous conversations discussing solutions for resources:

i also traced a related issue in ResourceService.indexNoteResources() (L82-91) - it parses note.body to extract resource IDs via Note.linkedResourceIds. with a locally encrypted body (JOPLIN_CIPHER:...), that parse returns nothing, which would cause setAssociatedResources to mark all the note's resources as is_associated = 0, eventually orphaning and deleting them. it already skips E2EE-encrypted notes at L82 - locally encrypted notes need the same skip.

While the resource management solution no longer involves calling setAssociatedResources directly, you do need to ensure the service which cleans unused resources will exclude clean up of resources where is_locally_encrypted is true. This means that orphaned encrypted resources will have to be deleted manually, but I think that is an ok compromise to avoid the complexity required to associate them.

An optimal way to do this could be to alter the NoteResources.orphanResources function to join to the resources table and exclude encrypted resources there. Please include details about this in the “Resource Handling, Mixed-State UI, & Option-Gated Resources” section.

Thank you so much for the kind words and the final polish! I really appreciate the time you've taken to work through the complexities of this architecture with me.

I have incorporated all of your final corrections into the proposal:

  • Cleaned up mentions: Removed the redundant item_changes bloat paragraph and purged the lingering vaultKey arguments from the option JSON text.

  • Restructured Note.load: Clearly separated the Default Path vs. Lock-Vault Path, moved the null migration logic there as a pre-requisite, and strictly enforced that Lazy Revision Cleanup only triggers when is_locally_encrypted evaluates to true.

  • UI Hooks & Conflicts: Explicitly labeled the Desktop vs. Mobile scopes for the UI hook injections and added the Phase 4 check to ensure the unlock UI does not override the new GSoC conflicts UI.

  • Orphaned Resources: Added the NoteResources.orphanResources JOIN modification to Section V to ensure encrypted resources are safely excluded from automatic background clean-up.

AkshajRawatIdea7Proposal-V5.pdf (864.7 KB)

Thanks. All good now!

Hi, I've been following this discussion closely while working on my own proposal for Idea #7. Really helpful to see the note revision issue raised. I hadn't fully considered that note history would contain unencrypted data after encryption, which essentially defeats the purpose if revision history is enabled. I'm now planning to handle this by clearing revisions on encryption (similar to the Secure Notes plugin's approach) and flagging proper revision encryption as a follow-up scope item.

One question for @mrjo118 you mentioned wrapping encryption/decryption around editor usages rather than intercepting Note.save()/Note.load() globally. For a plugin implementation, would joplin.workspace.onNoteSelectionChange be the right hook for triggering the decrypt-before-display flow, or is there a more appropriate editor-level event I should be targeting? I want to make sure my approach doesn't have the same unencrypted sync-back issue the Secure Notes plugin currently has. Also, can i add my proposal here for a lil guidance as well?

For a plugin implementation, would joplin.workspace.onNoteSelectionChange be the right hook for triggering the decrypt-before-display flow, or is there a more appropriate editor-level event I should be targeting?

Sorry I’m not very familiar with plugin development

Also, can i add my proposal here for a lil guidance as well?

You can, but as the deadline is in a few hours, you’re unlikely to get much guidance

I understand the timing is tight, but I’d really appreciate it if you could briefly take a look. Even a quick glance or a bit of feedback would be very helpful.

Zunairah_Joplin_EncryptedNotes_Gsoc'26_Proposal.pdf (168.1 KB)

I’ll just mention a couple of the major things then:

  1. Notebook level encryption is a lot more complicated than just a couple of lines in the proposal. See GSoC 2026 Proposal Draft – Idea 7: Support for encrypted notes and notebooks – moazhashem - #26 by mrjo118
  2. It’s not clear what the purpose of the plugin integration is. It looks like most of the change is changes to the core app, but there is a plugin to provide the actual UI to encrypt / decrypt the notes. Is that correct? Or will the full UI be implemented in the core app and the plugin does something else? Please add more clarity regarding this in the introduction

I’m not sure it’s convenient to type your password every time you open Joplin.

Since Joplin uses a SQLite database, wouldn’t it be simpler to just use the SQLite Encryption Extension (SEE) instead of building an entirely new system?