GSoC 2026 Proposal Draft – Idea 7: Support for encrypted notes and notebooks – moazhashem

GSoC 2026 Proposal Draft – Idea 7: Support for encrypted notes and notebooks – moazhashem

Links

Project Idea: Support for encrypted notes and notebooks

GitHub: Pixels57

Forum introduction post

Pull Requests for Joplin:

1. Introduction

I’m Moaz Hashem, I’m a senior computer engineering student at Cairo University, expected to graduate in 2026. I have hands-on experience in Node.js, TypeScript, Java, C++, Python, advanced database design: ACID guarantees, concurrency control, and vector databases for semantic search, cryptography, and security. I’ve built a lot of Software Engineering, AI, Security projects.

Personal Projects:

And a lot of other projects on my GitHub.

I apply SOLID principles and care about testable, maintainable code. I haven’t contributed to open-source before but looking forward to contribute to Joplin. Also I am planning to build a career in cybersecurity so I am passionate about it, and have always wanted to contribute to open-source projects.

2. Project Summary

What Problem it solves:

Joplin today protects note content in the cloud mainly through sync end-to-end encryption (E2EE): one master password for the whole vault, and encryption is effectively all-or-nothing at the sync layer. Users cannot give individual notes or notebooks their own passwords, and E2EE is not enabled by default , which limits how people can protect especially sensitive notes on a shared or unlocked device.

This project adds optional local “lock” encryption at the note level with notebook level independent of whether sync E2EE is on. It is informed by community discussion around stronger defaults and clearer encryption stories.

What will be implemented:

Note and notebooks bodies will use AES-256-GCM, with keys from PBKDF2 (e.g. HMAC-SHA256), a salt, and documented iterations. Passwords and raw keys are not stored only salt, ciphertext, and crypto metadata (IV, tag, KDF details as needed) in SQLite database.

Encrypt on save while locked, decrypt in memory after a correct password, wrong password or tampered data fails via GCM verification.

UI: context-menu lock/unlock, password dialogs, lock icons in list and tree, optional idle timeout for in-memory keys. FTS will skip locked content, like today’s encrypted notes.

Expected outcomes:

Users can optionally protect specific notes whole notebooks with separate passwords, with plaintext bodies not stored in the DB while locked. The feature remains compatible with existing sync E2EE behavior. Tests and docs reduce regressions and help future contributors.

3. Technical Approach

Data model and storage

We are going to add 3 columns to Notes table and 2 to Folders table:

  • lock_enabled: INTEGER NOT NULL DEFAULT 0 (0 = unlocked, 1 = locked).

  • salt: nullable TEXT, base64-encoded random bytes (16–32 bytes from randomBytes in crypto.ts

  • lock_cipher_text: nullable TEXT, per-note lock ciphertext (encoding aligned with EncryptionResult in types.ts), and this is not addded to Folders table.

  • encryption_applied / encryption_cipher_text: unchanged.

No password or key is persisted, only salt, lock_cipher_text, and lock_enabled on disk. When sync E2EE is on, the whole row is re-encrypted with the master key for upload (double encryption), the server sees only the outer E2EE blob.

Crypto

Reuse the existing layer: Crypto interface and EncryptionResult type in types.ts, PBKDF2 + AES-GCM in crypto.ts.

A new module will be added packages/lib/services/perNoteLock/crypto.ts, that will have functions deriveKey(password, salt), encryptBody(plain, password, salt), decryptBody(cipher, password, salt), Pass salt as decoded bytes or base64 string consistently with how it is stored in the column.

EncryptionService.ts remains unchanged for master-key sync.

Load and Save

Load:

  • In useFormNote.ts calls Note.load(noteId) before initNoteState.

    • If the note or its parent folder is locked, show an "Enter password" dialog, on correct password derive the key, decrypt, and call initNoteState with plain content.

    • Optionally cache the derived key or decrypted content in memory for the session, clear on lock or app close, or clear on timer.

Save:

  • Extend the Note.save() path in Note.ts, if the note is locked, encrypt the body with the password-derived key before writing, Never write plaintext to body while lock_enabled = 1.

UI

  • Context Menu: Add "Lock Note" button to Notes List Context Menu and "Unlock Note" button to Notes List Context Menu. Add "Lock Notebook" button to Folder Tree Context Menu and "Unlock Notebook" button to Folder Tree Context Menu. All context menus are triggered from useOnContextMenu.ts.

  • Dialogs: "Set password" and "Enter password" dialogs that mirror the MasterPasswordDialog, registered in appDialogs.tsx.

  • List and Tree: Add icons indicating each Note/Notebook's Lock status in the NoteListItem and NoteList2.tsx files and images in the Folder Tree.

Search

SearchEngine.ts excludes notes from the Full-Text Search (FTS) based on encryption_applied = 1. The same exclusion will apply to Locked Notes. In order to FTS Trigger and Query to Index/Select a Note for FTS the Index/Selection Query must include lock_enabled = 0. Any references to JoplinDatabase.ts that are indexed contain plaintext body for Locked Notes must be modified to remove the indexing.

Locking Logic

Clear the in-memory key/decrypted value for a note if there have been N minutes of inactivity following the last use. There will be no new database storage, the in-memory cache will utilize a timer and timestamps.

E2EE By Default Migration (issue #14465 alignment)

Dialog.tsx: add a prominent warning when setting the master password and an optional "Forgot password?" path using resetMasterPassword() from utils.ts.

Documentation and Testing

A developer doc will cover: where per-note data is stored, how the key is derived, and E2EE double-encryption interaction. Written incrementally alongside the implementation.

Unit tests will be added alongside each implementation phase, following existing repo patterns (Jest, test files next to the code under test). They will cover all implemented functionality to ensure correctness and prevent regressions.

Decisions (for maintainer sign-off)

Folder lock and children (we have 2 options):

  • Lazy encryption: folder row has lock_enabled + salt. Descendants are effectively locked by inheritance. Each note’s body is encrypted with the notebook password-derived key when it is next saved (or explicitly locked). No mandatory bulk re-encrypt on folder lock, avoids large sync spikes.
  • Immediate bulk: locking a folder encrypts every descendant note in one operation. Heavier; may need progress UI and conflict handling.

Title and FTS when body is locked (we have 2 options):

  • A: exclude note from FTS entirely (title and body), same as encryption_applied = 1. Strongest privacy, user cannot search locked notes by title.
  • B: Keep title plaintext in DB and index title only in FTS for locked notes. Better findability, weaker privacy for titles.

This is a block diagram that demonstrates the proposed architecture

4. Implementation Plan

Community Bonding Period (May 1 - 26):

Week 1 (1 - 7 May):

  • Community bonding: Set up dev environment, sync with mentors, clarify proposal ambiguities, familiarize with codebase and architecture, draft design notes, Learn more about Joplin as an organization with a mission and vision.

Week 2 (8 - 14 May):

  • Data model & crypto: Database support for lock fields, crypto helper (password + salt -> encrypt/decrypt note body) with tests.

Week 3-6 (15 May - 11 Jun):

  • Final exams and graduation project submission. No implementation. Remain active in the community, respond to mentor messages, follow discussions.

Week 7 (12 Jun- 18 Jun):

  • useFormNote.ts: (Load & save) Implement the note load and save paths for locked notes, with unit tests.

  • optional in-memory cache

Week 8 (19 Jun - 25 Jun):

  • End-to-end lock/unlock Flow, Context menu in NoteListUtils: "Lock note" / "Unlock note". "Set note password" and "Enter password" dialogs, (MasterPasswordDialog-style)

  • Lock icon in note list (NoteListItem, NoteList2)

Week 9 (26 Jun - 2 Jul):

  • Folder tree: Lock notebook / Unlock notebook, Lock icon for locked notebooks, Polish dialogs and copy.

  • In SearchEngine.ts: exclude locked body from FTS, JoplinDatabase.ts: update FTS triggers.

  • Optional: lock-after-timeout.

Week 10 (3 Jul - 9 Jul):

  • Per-notebook encryption: Folder lock: flag + salt in folder. Unlocking the notebook unlocks all notes in it. Tests for notebook lock/unlock, ensure all tests pass.

Midterm Evaluation (Jul 6 - Jul 10)

Week 11 (10 Jul - 16 Jul):

  • E2EE migration & docs: MasterPasswordDialog/Dialog.tsx: prominent warning when setting master password. Optional "Forgot password?" path using resetMasterPassword() (e2ee/utils.ts). Developer doc. Open PR and request reviews.

Week 12 (17 Jul - 24 Jul):

  • Remaining fixes, final PR(s), changelog

  • Preparation for final evaluation

  • Seeing if there is additional work to be done and seeking reviews before the final

5. Deliverables

  • Per-note encryption

    • Add optional per-note locking so a note is encrypted at rest with a password-derived key and only decrypted in memory after successful authentication.
  • Per-notebook encryption

    • Extend the same model to notebooks so all notes inside a locked notebook follow the same protection and access flow.
  • User interface and workflow

    • Provide lock/unlock actions, password prompts, and visible locked-state indicators in the desktop UI so protected notes and notebooks are easy to manage.
  • Reuse of existing crypto stack

    • Build the feature on top of Joplin’s existing encryption primitives and types to avoid duplicating crypto logic and keep the design aligned with the current codebase.
  • Search and indexing protection

    • Ensure locked note bodies are excluded from local search and full-text indexing so encrypted content is not exposed through plaintext search results.
  • Sync E2EE by default (optional)

    • Explore enabling sync E2EE by default with a safer onboarding flow, including clearer master-password setup and warning messages.
  • Testing and documentation

    • Add targeted tests and technical documentation covering encryption flows, locked note behavior, and expected interactions with the existing sync E2EE model, in order to have clean, reliable, and maintainable code.

6. Availability

  • Weekly availability: 30 hours per week

  • Time zone: (GMT +2)

Would appreciate your feedback when you have the time :slight_smile:

@mrjo118 @personalizedrefriger @Daeraxa

Thank you for submitting the draft proposal!

  • If you haven't already, I suggest reviewing this discussion for a similar proposal.
  • The proposed implementation currently seems to be desktop-only. How will the CLI and mobile apps handle notes encrypted on desktop?
  • How will note attachments be handled? (Will note attachments remain unencrypted?)

It can be extended to mobile app, and CLI using the same shared core logic as desktop.

Shared logic

These changes apply to desktop, mobile, CLI.

  • Add lock metadata fields and keep schema/sync behavior consistent. packages/lib/services/database/types.ts + migrations + packages/lib/JoplinDatabase.ts

  • Handle lock checks, lock-aware loading/saving, and sync serialization.
    packages/lib/models/Note.ts, packages/lib/models/Folder.ts, packages/lib/models/BaseItem.ts

  • Shared lock crypto helper (derive/encrypt/decrypt) so clients don’t implement crypto separately. And use the old EncryptionService.ts same as desktop.
    packages/lib/services/perNoteLock/crypto.ts.

  • DB FTS paths + Exclude locked plaintext from indexing/search.
    packages/lib/services/search/SearchEngine.ts

Mobile extension (UI/lifecycle integration)

  • Add lock-aware load/save gate in shared note screen lifecycle. packages/lib/components/shared/note-screen-shared.ts.
  • Will implement unlock prompt before edit/view when needed. packages/app-mobile/components/screens/Note/Note.tsx.
  • Render locked placeholder until decrypt/unlock succeeds.
    packages/app-mobile/components/NoteEditor/NoteEditor.tsx and packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx.
  • Show lock indicators and add lock-aware open/navigation behavior. packages/app-mobile/components/NoteItem.tsx and packages/app-mobile/components/side-menu-content.tsx.

CLI extension (command-layer integration)

CLI uses same shared lock semantics, with command-level guards:

  • Central lock guard for mutation commands (similar to existing encryption guard style).**
    packages/app-cli/app/base-command.ts**

  • Locked note returns safe locked output/instructions (not plaintext). packages/app-cli/app/command-cat.ts

  • Require unlock before editing and keep save path lock-aware.packages/app-cli/app/command-edit.ts

  • Show lock state in listings.
    packages/app-cli/app/command-ls.ts

  • Reuse guard so no command becomes a bypass.
    packages/app-cli/app/command-set.ts, command-mv.ts, command-ren.ts, command-attach.ts

About the attachments handling

For per-note/per-notebook lock, resources will follow a lazy model:

  • Resource metadata stays in SQLite (resources/note_resources), but actual attachment bytes remain file-based in resourceDir (as today).

  • Under effective lock, saved copy on disk for resource files is lock-encrypted.

  • We will not decrypt all attachments when opening a note (to avoid for example a note having a lot of attachments PDFs or Videos each of size 1GB for example that would be pretty intensive).

  • Decryption is on-demand per resource (open/preview/download), with in-memory session key reuse and timeout-based cleanup.

  • When syncing Joplin still applies its normal E2EE on top. Note/resource lock encryption stays inside that, so data is protected twice when both are enabled.

  • If password is wrong or data check fails keep the item locked (do not show content).
    Any temporary decrypted files are deleted when the note is locked again, when session timeout expires, or when the app closes.

Resource Lock Implementation Plan

Data model

  • Add resource lock metadata in shared schema (packages/lib/services/database/types.ts + migrations):

    • fields: lock_enabled, lock_scope_type (note/folder), lock_scope_id, lock_updated_time.

Lock crypto and file pipeline

  • Add packages/lib/services/perNoteLock/resourceCrypto.ts:

    • derive key from note/folder password + salt

    • encryptResourceFile(inputPath, outputPath, keyMeta)

    • decryptResourceFile(inputPath, outputPath, keyMeta)

  • Use streaming/file crypto.

  • Store only metadata on DB, keep bytes file-based in resourceDir.

Lock resolution and session cache

  • Add shared resolver (in a new module like packages/lib/services/perNoteLock/effectiveLockResolver.ts):

    • resolveEffectiveLockForNote(noteId) + resolveEffectiveLockForResource(resourceId)
  • Add in-memory session cache service (in a new module like packages/lib/services/perNoteLock/LockSessionCache.ts):

    • noteKeyCache[noteId]

    • folderKeyCache[folderId]

    • TTL (Time to Live) + invalidation on relock/password change/move/exit.

Resource access interception

  • Intercept all resource-open/read paths via shared services (Resource.ts, ResourceFetcher.ts, DecryptionWorker.ts integration points):

    • if unlocked session exists → decrypt requested resource only

    • else → request unlock flow from caller (desktop/mobile/CLI)

  • Return locked placeholder/error instead of plaintext when not authorized.

  • Never bulk-decrypt resources on note open.

There are some things that I was concerned about, and that is what I thought about:

Old-client compatibility risk for per-item lock fields

New lock metadata columns on notes, folders and resources are added, and older clients do not understand these fields. During sync, old clients can drop unknown fields when reserializing items, which can cause lock metadata loss and potentially unsafe state.

To prevent this, I was thinking to use Joplin’s standard compatibility mechanism: bump sync appMinVersion for the release that introduces per-item lock metadata. Once enabled on a sync target, clients below that version will be blocked from syncing that profile until upgraded.

RevisionService Plaintext History and Lock-Transition Revision Leakage.

RevisionService can create revisions when title, metadata changes even if body is empty, placeholder. With per-note lock, this can produce noisy lock-transition revisions and leave old plaintext history recoverable unless explicitly handled.

The fix that I thought of:

  • Mark lock/unlock and encrypted-only rewrite saves with a dedicated change source (e.g. SOURCE_LOCK_TRANSITION) and skip them in revision collection.
  • On locking a note, delete existing plaintext revisions for that note via RevisionService.deleteHistoryForNote() before the lock transition.
  • For effectively locked notes, revision flow must never persist readable plaintext content.

This will be implemented in shared packages/lib, so desktop/mobile/CLI behave consistently.

Ciphertext Diff Noise in item_changes

Encrypted payloads change every save because a new IV/nonce is used each time. This means item_changes, before_change_item can produce very large diffs that are cryptographic churn, not real user edits.

I thought about:

  • Treat encrypted-only rewrites as non-semantic changes
    Lock-transition saves and ciphertext-only rewrites will be marked with a dedicated change source (for example SOURCE_LOCK_TRANSITION).
  • Exclude these changes from revision generation
    RevisionService will skip these change sources so we don’t create huge, meaningless history entries.
  • Keep semantic history only
    We preserve meaningful user content revisions, while lock/unlock can be represented as minimal metadata events (not ciphertext diffs).
  • Fail-safe behavior
    If change classification is ambiguous, we do not generate revisions for effectively locked notes.

This keeps revision history useful and prevents storage overhead from IV-driven ciphertext churn.

Hidden Reload/Save Path Lock-Gate Bypass Risk

Lock checks only in useFormNote/Note.save are not enough. There are alternate reload/save paths (for example editorNoteReloadTimeRequest/EDITOR_NOTE_NEEDS_RELOAD) that can reload note data outside the primary path. If any of these paths bypass lock gating, raw ciphertext could be pushed into editor state.

I thought of:

  • Centralized load gate
    Introduce a lock-aware note loader in packages/lib and require all editor load/reload paths to use it (desktop/mobile/CLI integration paths included).
  • No direct editor hydration from raw Note.load()
    Any path that initializes editor/view state must first run effective-lock check + unlock/session validation + decrypt.
  • Centralized save gate
    Apply the same principle to save/update paths so lock transitions and encrypted writes cannot bypass policy through alternate command/API/reload flows.
  • Fail-safe invariant
    Ciphertext must never be sent directly to editor/view state. On ambiguity or mismatch, fail close the note, and keep locked.

This resolves hidden reload paths while keeping behavior consistent across clients.

Notebook Lock Session Cache (No Per-Note Password Spam)

With per-note locks, users could be prompted for a password on every note in a locked notebook. That’s bad UX. A single vault key (E2EE) is easy to cache, per-item locks need a clear rule for which password applies and when it’s already satisfied.

This can be fixed by:

  • Notebook-primary lock model
    A locked notebook uses one notebook password for all notes inside it (unless a note has its own password).
  • Scoped in-memory session cache (never persisted)
    • folderKeyCache[folderId] after the user enters the notebook password once, subsequent notes in that notebook reuse the cached derived key until timeout/relock/exit.

    • noteKeyCache[noteId] for notes with an explicit per-note lock override.

  • Priority rule
    If a note has its own lock, use the note key. otherwise walk ancestors and use the locked folder’s key.
  • Invalidation
    Clear the relevant cache entries on relock, password/salt change, moving a note between notebooks, session timeout, and app exit.

After you enter the notebook password once, we keep a short-lived in-memory entry for that folder. Opening another note in the same folder reuses that entry, so you aren’t asked again until timeout or relock.

These are the things that concerned me, and thought about workarounds for them, I would to get your feedback about them. And tell me what do you think or if you have any concerns

Thank you for the update! How

Also, I'm attaching a quick reminder of Joplin's policy for AI use in proposals. If AI was used (e.g. for rewording or spelling/grammar correction), be sure to include an "AI Assistance Disclosure" section. Please also read Joplin's GSoC AI Policy for proposals. (If AI wasn't used, no need to change anything :slight_smile: ).

How would this work for images/audio/PDFs that are displayed inline within a note?

We can show a blurry box that says ("Sensetive information"), and show button when clicked it reads the inline resource, decrypt it, render it, and then cache it in memory.

I used AI to fix any grammatical issues and better phrasing, but I forgot to put the disclaimer going to edit, and add it. Thanks for reminding me.

1 Like

I would like to know your opinion about this approach @personalizedrefriger

That could work. Perhaps it could build on the existing "manual download" mode for attachments (which may not work in the Rich Text Editor).

Comments:

  • Attachments can be associated with multiple notes. For example, if a user locks an existing note that shares resources with other notes, the note encryption feature should be able to handle this. (See comment).
    • Because of sync, it can be difficult to tell whether an attachment is associated with multiple notes.
    • One solution might be to duplicate/copy resources when locking an existing note. However, this approach could result in a large number of duplicate attachments (and would keep a copy of the original attachments, unencrypted). For reference, there's existing logic for ensuring that each attachment is associated with at most one server share by duplicating attachments.
  • The proposal suggests adding new syncable database properties to notes, folders, etc. This could cause compatibility issues with older Joplin clients during sync if these properties are discarded. (See @mrjo118's comment in a similar thread, sync serialization logic).

We can make it work for Rich Text Editor, to support manual decryption. It will be a bit complex but doable. I don’t currently see a better workaround to avoid bulk decryption.

This can be handled by two ways, either we create an encrypted copy for each resource and link it to the encrypted note, but that will increase disk usage like you said, and will cause duplication, as there will be different ciphertexts for each encrypted note, but each note will have its own encrypted blob with its own note password, or we can add a new master password for resource lock, similar to E2EE, that encrypts and decrypts shared resources for locked-notes which makes it doesn’t rely on per-note password, which will decrease disk usage and duplication, locked notes reference a shared encrypted resource id created when the first note locks that attachment. Unlocked notes reference the usual resource id.

It was stated that older clients will strip DB fields, and that will cause encryption state to be lost.
If we cannot control the minAppVersion like mentioned, we can preserve, the new fields, and flags in body field, cache in it the flags, and metadata we want to restore, and encode the ciphertext to it, so it can be recovable, but the UI will be strange on older versions, as they will not support, locking UI, as unreadable content will be shown in the body from the perspective of the end user or break the envelope if they edit. So that is why I thought about enforcing a minimum version for the app to force users to upgrade and be safe, rather than relying on older versions to handle locked notes.

Hello @personalizedrefriger,

Any other comments or should I proceed with that?

This is the latest version of the proposal, I’ve worked through the concerns and issues we discussed, I am looking forward to hear your feedback :slight_smile:. @personalizedrefriger @mrjo118

MoazHashem_JoplinProposal.pdf (1.3 MB)

I am Currently working on some PoCs.

encrypted_source_resource_id: nullable TEXT. Holds
one encrypted blob (AES-GCM-SIV), not a plain id, the peer id in
the id_open / id_locked pair, encrypted with the resource
master key. Without the key the link is unreadable, with it the app
decrypts using deterministic encryption.

Adding an additional column here would have the same issue of backwards compatibility, and I’m not sure Laurent will want to use the minAppVersion constraint.

Also, why does there need to be a separate salt column adding to notes? Is your plan to use the existing encryption functions (used for E2EE) for the local encryption, or something else, and if so why?

Same attachment in multiple notes:
A single resource can be referenced by more than one note (by
copy-paste or shared content). Because there is only one database row
per resource, that row cannot carry two incompatible encryption states at
once. So we keep two versions of the file, an unencrypted one
(id_open, used by unlocked notes, with standard E2EE handling for
sync) and an encrypted one (id_locked, used by all locked notes
referencing the same attachment).

This approach doesn’t make sense to me. There’s really no point storing encrypted resources if an unencrypted version is readily available in the same profile (can easily be searched in the attachment management screen). It defeats the purpose of using encryption in the first place. I proposed a solution for managing encrypted resources on the other proposal threads for this project

I’m not sure Laurent will want to use the minAppVersion constraint.

Actually I think you can ignore this part (FYI it’s actually appMinVersion). I think appMinVersion can be used independently to sync migrations (which we should not use), and it should provide a means to prevent old clients from wiping data in new fields on the server items. I’ll need to have a deeper look at it though

What are the concerns for relying on appMinVersion approach?

We can use the JOPLIN_CIPHERTEXT envelope so we can recover important lock metadata if older clients drop unknown fields, if we don't use the appMinVersion approach.

Could you please reference the resource-handling solution you suggested in the other thread? I’d like to take a look at and align with that approach.

My current understanding is that if a locked note and an unlocked note share the same resource, it remains accessible through the unlocked note. No direct relation in metadata exposes that the shared resource is used by a locked note, that association stays hidden, and encrypted resource stays hidden

I had concerns, but after looking into it, now I don’t

Could you please reference the resource-handling solution you suggested in the other thread?

Read the further thoughts section and onward in this post GSoC 2026 : Local Note Encryption (Draft proposal and POC) - #87 by mrjo118

My current understanding is that if a locked note and an unlocked note share the same resource, it remains accessible through the unlocked note. No direct relation in metadata exposes that the shared resource is used by a locked note, that association stays hidden, and encrypted resource stays hidden

Joplin has a screen where you can view all attachments which have ever been included in any note, so unless you delete or replace the unencrypted version, it will be available there. Also if your device is compromised, the unencrypted data can be obtained from the Joplin data if it is duplicated without encryption at rest

why does there need to be a separate salt column adding to notes? Is your plan to use the existing encryption functions (used for E2EE) for the local encryption, or something else, and if so why?

Also, could you answer these questions please?

So are we cool with proceeding with that, and keeping the JOPLIN_CIPHERTEXT prefix in body, so if appMinVersion check failed or bypassed in any way there is a backup plan to recover lost metadata?

Thanks for referencing the post :slight_smile: .

I have read the solution you proposed, you are right it does not make sense storing duplicates, and it will degrade disk usage, and it is better to not encrypt shared resources, and warn the user that they are not encrypted as they are shared, it puts the user in control and raises the visibility of how the flow works to the user.

This is how I will update technical appraoch in my proposal:

Data model and storage Section

Add is_locked flag to resources table: NULLABLE INTEGER with same pattern 0 (not locked) / 1 (locked) / null (undefined)

Discard the addition resource_master_key_id and encrypted_source_resource_id.

Encrypted resource file content on disk uses AES-256-GCM, the file begins with a JOPLIN_CIPHER: prefix, so that if is_locked was stripped by an old client, a gated load can detect encryption and repair the flag, same idea as the note-body prefix, with exact delimiter chosen to avoid parsing ambiguity.

Attachments and Shared Resources Section

A resource id can appear in more than one note. note_resources is the fast path to detect sharing. When locking a note, if a resource is already used in other notes, do not encrypt that file leave it as-is and show a warning that some attachments were not encrypted because they are shared elsewhere, and that the user should upload a separate copy if they want those bytes encrypted. When unlocking a note that contains resources, show a confirm prompt to decrypt with cancel: decrypting those resources may also make them available in other notes if they are shared there.

Mixed-state UI:

Wherever the note’s lock state and a resource’s is_locked state disagree, show a clear indicator. An encrypted resource inside an unlocked note shows a locked placeholder, opening it user will be prompted (vault password to remove local encryption globally).
Attachment management lists encrypted resources with a lock icon and requires a password prompt to open, not a generic read error.

Cleaning up unused resources:

Automatic orphan cleanup will skip resources with is_locked = 1 so ciphertext is not deleted when the note_resources table is temporarily out of sync with what’s actually in the note, users may remove those files manually from attachment management when safe. Ordinary unencrypted orphans continue to follow existing rules.

alter NoteResources.orphanResources or by skipping Resource.delete in ResourceService.deleteOrphanResources() to exclude resources where is_locked = 1, so encrypted files are not auto-deleted when associations are ambiguous.

Notebook bulk lock/unlock:

Always show a confirmation prompt before recursive lock/unlock of a notebook, because cascading work touches mixed resource states.

Crypto Section

  • Resources are option-gated load/save (mirror note gating) for desktop, mobile, CLI, and REST where resources are exposed, encrypt/decrypt of file streams will happen with AES-256-GCM.

  • The JOPLIN_CIPHER for resource files is prefix in a blob on disk, when is_locked is NULL, we extract previous lock state, acts as a guard against double local encrypt/decrypt using the flag.

  • Discard AES-GCM-SIV proposed to encrypt/decrypt link between shared resource copies, in the old approach.

Please correct me, if I got anything wrong.

It was meant to be in a separate column for fast access, but as we are storing JOPLIN_CIPHERTEXT prefix in body, we can store the salt in the prefix and not add salt column if that is preferred.

I am going to reuse the existing crypto stack: the Crypto interface and EncryptionResult in services/e2ee/types.ts, and PBKDF2 + AES-GCM in services/e2ee/crypto.ts.

Add services/perNoteLock/crypto.ts with encryptBody(plain, password), and decryptBody(body, password), and deriveKey(password, salt) as we will cache derived keys in memory. These functions build or parse the JOPLIN_CIPHERTEXT envelope in body, embedding salt, IV, and ciphertext which will be compatible with EncryptionResult. EncryptionService.ts stays focused on master-key sync E2EE and is not used as the API for per-note lock.

I am not using EncryptionService for local lock, as It is tied to master keys, sync encryption methods, and E2EE fields. Per-note lock uses a different passwords, stores ciphertexts in body, and needs editor/save gating, so we call the same shim.crypto primitives from a dedicated small module instead.

It was meant to be in a separate column for fast access, but as we are storing JOPLIN_CIPHERTEXT prefix in body, we can store the salt in the prefix and not add salt column if that is preferred.

Ok I hadn’t realised you’re not using a shared encryption key for for all locked notes / notebooks

So are we cool with proceeding with that, and keeping the JOPLIN_CIPHERTEXT prefix in body, so if appMinVersion check failed or bypassed in any way there is a backup plan to recover lost metadata?

Yes use appMinVersion, now I’ve confirmed it is ok to use. You don’t need to use the JOPLIN_CIPHERTEXT prefix at all anywhere in the db in that case, so just store new data values in separate db columns instead of prefixing the body, as it’s cheaper than extracting values. But you still do need to ensure is that the code will handle server items where the new values are not populated, however there is no longer a risk of data loss from old server items

Yes, each lock has its own derived key (per-note, or per-notebook).

Okay will keep the appMinVersion approach, but that means we are keeping salt in a seperate column right?

Do you think it is better idea when the note is locked to store the ciphertext in a new column like lock_ciphertext, and body will be having an empty string, or store the ciphertext in the body field like before?

unpopulated server items can be handled by assigning default values.

This is the latest version of my proposal.

Updated the things we discussed.

MoazHashem(v2).pdf (2.3 MB)

Happy to hear your feedback :slight_smile:

P.S. for some reason I can’t edit first post of the thread with my proposal.

that means we are keeping salt in a seperate column right?

Yes

Do you think it is better idea when the note is locked to store the ciphertext in a new column like lock_ciphertext, and body will be having an empty string, or store the ciphertext in the body field like before?

You should still keep the content in the existing body field, otherwise there is going to be issues with conflict resolution. We don’t want to introduce dependencies to the conflict resolution GSoC project