GSoC 2026 Proposal Draft - Idea 7: Local Note Encryption - keshav0479
Links
-
Project idea: Idea #7: Local Note Encryption
-
GitHub: github.com/keshav0479
-
Forum introduction: Welcome to GSoC 2026 with Joplin!
Joplin PRs:
| PR | Description | Status |
|---|---|---|
| #14776 | Mobile: Fix OneDrive auth code double-encoding | Merged |
| #14733 | Mobile: Restrict sidebar gestures to notebook list | Merged |
| #14572 | Desktop: Preserve table customization in Rich Text Editor | Merged |
| #14544 | All: Delete orphan .crypted files at startup | Closed (performance concern on USB/network drives) |
| #14512 | Mobile: Enable follow link tooltip in markdown editor | Closed |
Other relevant experience:
- RoboSats: 10 merged PRs, including #2382 (encrypted image upload with XChaCha20-Poly1305, BUD-01/02 Blossom auth, NIP-17/NIP-59 privacy wrapping, 15 commits)
1. Introduction
I'm Keshav, a pre-final year Mathematics and Computing student at NIT Kurukshetra, India (IST, UTC+5:30). i've been contributing to open-source for the past year, mostly in the bitcoin/privacy space.
Most of my recent work has been on RoboSats, a P2P bitcoin exchange. i have 10 merged PRs there across frontend (React/TypeScript) and backend (Python/Django). the one most relevant here is #2382, where i built encrypted image sharing for the trade chat using XChaCha20-Poly1305 via @noble/ciphers, with BUD-01/02 authentication and NIP-17/NIP-59 privacy wrapping. that was production cryptography on a platform that handles real money, and it taught me a lot about thinking carefully about where plaintext can leak.
I found Joplin through issue #9093 (orphan .crypted files). working on PR #14544 took me through EncryptionService, BaseItem.serialize/unserialize, the sync flow, and how E2EE interacts with resource storage. the PR was closed because walking the resources directory at startup would be too slow on USB and network drives. that feedback stuck with me and directly shapes how i think about performance tradeoffs in this proposal. since then i've had 3 PRs merged across mobile and desktop.
2. Project Summary
Problem
Joplin's E2EE encrypts data for sync. it protects notes on the wire and at rest on the sync target. but it doesn't protect notes on the local device. if someone opens your laptop, they can read everything. there's no way to lock specific sensitive notes behind a password.
the existing Secure Notes plugin tries to solve this, but has known limitations that go beyond what the plugin API can fix. mrjo118 identified the core issue directly in that thread: "The encryption is basically redundant unless either the user completely turns off note history, or they delete all note history for a note after every time they encrypt a note." the plugin author acknowledged: "that's a great catch, I totally missed that." resources also aren't encrypted (the author confirmed: "resources in .md file is just a hyper link"). these are plugin API limitations, not bugs in the plugin itself.
What will be implemented
Note-level local encryption that lets users lock individual notes (or bulk-lock all notes in a notebook) behind a vault password. locked notes store ciphertext in the body field with a JOPLIN_CIPHER: prefix, and decrypt on-demand when the user provides credentials.
Expected outcome
-
users can lock/unlock notes through a context menu and vault password
-
locked notes show a lock screen in the editor, not plaintext
-
the design goal is that search index, revision history,
item_changes, and sync payload should not contain plaintext for locked notes (section 3.3 discusses the open challenges here) -
the core feature works on desktop, with mobile and CLI support as stretch goals
-
coexists cleanly with E2EE (separate concerns, separate keys)
Out of scope
-
per-note passwords (single vault password only)
-
notebook-level encryption as a folder concept (notebook actions are bulk operations on child notes, not a folder schema change)
-
title encryption (body field only)
3. Technical Approach
3.1 Data model
One new column on the notes table (not the folders table, since notebook locking is just a bulk operation on child notes, not a folder-level concept):
ALTER TABLE notes ADD COLUMN is_locally_encrypted INTEGER DEFAULT NULL;
there is no separate local_cipher_text column. the encrypted content is stored directly in the body field with a JOPLIN_CIPHER: prefix (see rationale in post #20).
is_locally_encrypted is nullable with three states: 1 (encrypted), 0 (explicitly not encrypted), null (never set / old client). the null state triggers a body.startsWith('JOPLIN_CIPHER:') fallback check on load, while 0 skips it entirely (performance optimization for non-encrypted notes). during that gated load, migration sets the flag to 1 or 0 and persists it immediately, so subsequent loads use the flag directly.
the NoteEntity interface (packages/lib/services/database/types.ts, L223) gets extended with is_locally_encrypted. it also gets added to Note.previewFields() (Note.ts, L350) so the note list can show a padlock icon without loading the full note.
3.2 Option-gated Note.save / Note.load
Encryption logic only runs when a useLocalEncryption option is explicitly passed in SaveOptions/LoadOptions (models/utils/types.ts). useLocalEncryption: true is passed on all code paths where local encryption may need to be applied, regardless of whether the note is actually encrypted. existing callers of Note.save/Note.load that do not pass this option continue unchanged.
within the gated load path, migration logic (null → 1 or 0) executes first as a prerequisite, before checking whether is_locally_encrypted is true. the save and load behaviors described below only activate when is_locally_encrypted is evaluated as true (initially or via migration).
Save path (Note.ts, L777):
when saved with { useLocalEncryption: true }:
-
encrypt
bodyusing the vault master key, prependJOPLIN_CIPHER:prefix, store result back inbody, setis_locally_encrypted = 1. ordering note (from mrjo118, post #16): this logic must execute before thechangedFieldsarray is computed (Note.ts, L833), otherwise the save model won't detect the body change -
set
beforeChangeItemJsonto null (Note.ts, L849) whenis_locally_encryptedis true. since body now contains ciphertext that changes on every save (different IV each time), the old-vs-new body diff would capture cipher values and bloatitem_changes
without the option, zero change in behavior.
Load path (Note.ts, L773):
when loaded with { useLocalEncryption: true } and is_locally_encrypted = 1 (or null with JOPLIN_CIPHER: prefix in body):
-
retrieve the decrypted vault master key from
EncryptionService.decryptedMasterKeys_ -
strip the
JOPLIN_CIPHER:prefix, decrypt the cipher, set the plaintext on thebodyfield of the returned in-memory model. the DB body still holdsJOPLIN_CIPHER:...(confirmed in post #22) -
delete any unencrypted revisions for this note (revisions where the encrypted flag is not set). this handles plaintext revision cleanup on every load, including cross-device scenarios where plaintext revisions may have synced before encryption was enabled (from mrjo118, post #43)
-
if
is_locally_encryptedwas null, gated load performs migration immediately (set to1if prefixed, else0) and persists it so future loads skip the prefix check -
if the key isn't loaded (vault not unlocked this session), return with
body = ""plus a flag for the UI to show an unlock prompt
in the guaranteed core scope, the desktop editor and viewer routes pass this option. CLI, API, and mobile routes are stretch-phase additions (weeks 9-12).
confirmed design direction (from discussion with mrjo118): the option-gated approach works to the same effect as a thin layer outside Note.save/Note.load, but without requiring code duplication. the option flag keeps the encrypt/decrypt logic colocated with the existing save/load code paths.
critical save/load paths to gate:
all UI save and load flows that handle note content must pass useLocalEncryption: true for encrypted notes. a specific example: in mobile Note.tsx, reloadNoteAndUpdateRefreshKey() calls shared.reloadNote() which goes through Note.load without any option gate. if the note is encrypted and this reload triggers (via editorNoteReloadTimeRequest changing), it could load ciphertext without decrypting, and a later edit could write that back incorrectly. this path must include the option gate. note: the mobile reload path is documented here for completeness but would be gated and tested during the stretch phase (weeks 9-10).
partial saves are safe by design: mobile's saveOneProperty and CLI's sparse saves pass sparse objects (e.g. { id, title }) through Note.save without touching the body field, so they don't interfere with the encryption layer.
3.3 The body field challenge
with the body-prefix approach, encrypted notes store JOPLIN_CIPHER:<cipher> in the body field. the UI mounts the body field and runs effects when it changes. to display decrypted content, the gated load replaces body with plaintext on the in-memory model, while the DB retains the cipher.
the risk: a non-gated save path could persist the in-memory plaintext back to the DB, overwriting the ciphertext.
mitigation plan:
-
the gated load sets plaintext on the in-memory model's body. a non-gated load returns the raw
JOPLIN_CIPHER:...string (the cipher, not plaintext). so only code paths that explicitly passuseLocalEncryption: trueever see plaintext -
a non-gated save would write whatever body the caller has. if the caller loaded via non-gated path, body is the cipher string (safe). the risk is only if a gated load's plaintext model reaches a non-gated save path
-
this is addressed by:
-
integration tests that assert no DB write ever contains plaintext for a locked note
-
careful tracing of all save/load flows during implementation
-
the
editorNoteReloadTimeRequestreload path and similar non-obvious routes are explicitly flagged for testing
search indexing:
SearchEngine.ts: bothdoInitialNoteIndexing_(L140) and the incrementalsyncTables_query (L238) currently select all non-E2EE, non-conflict notes. both need anis_locally_encryptedfilter to exclude locked notes from the FTS index, so the search engine never indexes ciphertext
revision behavior (updated based on mrjo118's analysis in post #24, post #43):
-
on every gated load of an encrypted note: delete any unencrypted revisions for that note. this serves as the single cleanup point for plaintext revisions, handling both the initial lock transition and cross-device scenarios where plaintext revisions may have synced before encryption was enabled on another device
-
on subsequent saves of already-locked notes: rather than opting out of revisions entirely, encrypted notes use a standalone revision strategy: each revision is created with no parent ID, so it always contains the full contents and doesn't require merging diffs with other revisions. this avoids the performance issue of evaluating large diffs from cipher changes, while still maintaining a revision history for encrypted notes (mentor-discussed direction, exact implementation to be validated)
-
cross-device revision edge case (discussed with mrjo118, post #26): if unsynced plaintext revisions exist on device A and encryption is enabled on device B, the plaintext revisions could sync later. the gated-load cleanup above handles this: whenever the note is opened, unencrypted revisions are deleted. while the plaintext revision may persist briefly, it gets cleaned the first time the user opens the note on that device. this requires standalone revisions (above) so encrypted revisions don't depend on deleted plaintext revisions for diff merging
-
on unlock transition (locked -> unlocked): do not flush the encrypted revision, it serves as a recovery path in case of decrypt corruption (from cipherswami's plugin experience)
-
revision viewer unlock UI (from discussion with mrjo118, post #29):
NoteRevisionViewerneeds a password gate similar to the editor lock screen. whenis_locally_encryptedis true on a revision, the viewer shows an unlock prompt before decrypting and rendering the revision content. scoped into weeks 7-8 with the rest of the revision work
3.4 Master key architecture
single vault master key generated via EncryptionService.generateMasterKey() (EncryptionService.ts, L312) with a new source constant SOURCE_LOCAL_VAULT. this keeps it fully isolated from E2EE keys, so enabling or disabling E2EE has no effect on vault keys.
password change: re-encrypt the master key with the new password. notes themselves stay untouched.
password reset: generate a new master key. notes locked with the old key become unrecoverable. the UI shows a clear warning before proceeding.
session lifetime: password entered once per session. the decrypted key lives in EncryptionService.decryptedMasterKeys_ and gets cleared on app quit. session cache timeout is configurable in settings.
key loading isolation: loadMasterKeysFromSettings (utils.ts, L155) loads all master keys via MasterKey.all(). the vault key must be filtered by SOURCE_LOCAL_VAULT in this path to avoid interfering with E2EE active-key selection and password resolution (vault password ≠ E2EE master password).
3.5 Sync compatibility and old-client safety
this is the hardest part of the design, and easy to get wrong.
the problem: there's no way to enforce that all clients update at the same time. if a user edits an encrypted note on an old client, that client's sync engine doesn't know about is_locally_encrypted. the old client could wipe that field from the server object, as mrjo118 pointed out in the discussion thread, post #69.
the solution: since the cipher lives directly in the body field with the JOPLIN_CIPHER: prefix, old clients can't wipe it. they see body as a regular string and sync it as-is. no separate column mapping is needed.
when downloading from sync:
-
if
is_locally_encryptedis1, the note is encrypted. strip prefix on gated load -
if
is_locally_encryptedisnull(old client scenario or pre-upgrade), check if body starts withJOPLIN_CIPHER:. if so, treat as encrypted and migrate the flag to1immediately -
if
is_locally_encryptedis0, it's a normal plaintext note (skip prefix check for performance)
edge cases:
-
an old client sees
JOPLIN_CIPHER:...as the note body. it's not human-readable, but it's ciphertext, so there's no privacy risk. the garbled content should signal to the user that they need to upgrade -
the prefix appearing naturally in user content is extremely unlikely, especially combined with the fact that the body would also need to fail decryption
upgrade migration (from discussion with mrjo118, post #20): if an old client had already synced encrypted notes (body = JOPLIN_CIPHER:xxx) before upgrading, the new is_locally_encrypted column initializes to null. the fallback prefix check in the load path handles this cleanly, and the flag is healed on next save. this was the key reason for choosing body-prefix over a separate local_cipher_text column
E2EE coexistence (from discussion with mrjo118, post #13):
the body field contains JOPLIN_CIPHER:<cipher>. when E2EE is enabled, the sync engine treats this as a standard string and wraps it in an E2EE envelope. no special handling needed. the EncryptionService encrypts the full body (which is already locally encrypted), and on download the DecryptionWorker decrypts the E2EE layer, restoring the JOPLIN_CIPHER:... body.
-
is_locally_encryptedis excluded from E2EE encryption (likeidandtype_) so it's always visible in the clear on the server item -
the
JOPLIN_CIPHER:prefix fallback only runs whenis_locally_encryptedis null (old client scenario). once all clients update, the flag is the durable marker
3.6 Conflict safety testing
conflict safety (no data loss or corruption) is in scope for this project. full conflict UX for encrypted notes is not.
test scenarios (from discussion with mrjo118, post #5):
| scenario | what to verify |
|---|---|
note conflict duplication (handleConflictAction -> createConflictNote) |
is_locally_encrypted and cipher body survive duplication correctly |
| note conflict with remote deletion | encrypted note metadata and cipher body are preserved or cleaned up safely |
editorNoteReloadTimeRequest reload with encrypted note |
option gate is enabled on reload path so ciphertext is not loaded as plaintext |
| mixed old/new client sync during conflicts | JOPLIN_CIPHER: prefix fallback handles conflict correctly when one client doesn't have is_locally_encrypted |
| partial save during conflict resolution | sparse saves don't overwrite cipher body |
| cross-device revision leak on encryption transition | revisions without the encrypted flag are cleaned up when encryption is enabled via sync |
| resource conflict with encrypted resources (stretch) | tested once resource encryption (3.9) is implemented |
| unlock UI vs conflicts folder | verify unlock UI does not override conflicts UI for notes in the conflicts notebook; exclude conflicts notebook notes from unlock UI if necessary |
3.7 Core changes needed (why not plugin-only)
the Secure Notes plugin thread demonstrates the limits of a plugin-only approach. following discussion with laurent and mrjo118 (post #23, post #24, post #26), here are the specific core changes needed:
-
revision handling:
RevisionService.collectRevisions(L151) needs encrypted-note handling. cipher changes completely between saves, so diff-based revisions store the full content every time. core change: create standalone revisions (no parent ID, skipping the merge path atcreateNoteRevision_L105-109) for encrypted notes, so revisions are self-contained. also need a flag on the revisions table to handle cross-device cleanup on encryption transition. plugins can't modifyRevisionServiceinternals. -
search indexing:
SearchEngine.tshas two indexing queries (doInitialNoteIndexing_L140, incremental sync L238) that include all non-E2EE notes. core change: addis_locally_encryptedfilter to both. this is a simple SQL filter. could potentially be a plugin API (search exclusion), but adding it directly is more efficient than a per-query hook. -
editing transparency: a plugin can't gate
Note.loadacross all internal paths, so it must build its own editor panel. core change: option-gatedNote.loadlets the existing markdown/rich text editor work transparently with encrypted notes. -
item_changes suppression:
Note.save'sbeforeChangeItemJson(L849) captures full cipher diffs on every save due to IV rotation. core change: set to null whenis_locally_encryptedis true. internal toNote.save, not plugin-accessible. -
resource cleanup: plugin API has
onStartbut noonExithook. decrypted temp files need cleanup on app quit (desktop) and startup sweep (mobile). core lifecycle management can handle this across platforms. -
cross-device revision safety: unsynced plaintext revisions on one device could sync after encryption is enabled on another. core change: migration-on-load cleanup (see 3.3). requires core-level access to the revisions table.
-
orphaned resource protection: the background indexer (
ResourceService.indexNoteResources) parsesnote.bodyto extract resource IDs. for locally encrypted notes, the ciphertext body yields no IDs, which would mark all associated resources asis_associated = 0, eventually causingNoteResources.orphanResources()(NoteResource.ts, L159) to flag them for deletion. core change: alterNoteResources.orphanResources()to JOIN the resources table and exclude resources whereis_locally_encrypted = 1. this means orphaned encrypted resources must be deleted manually, which is an acceptable compromise to avoid the complexity of associating them while preventing accidental data loss.
credit to cipherswami (Secure Notes plugin author) for input on revision flush timing and corruption risk considerations which informed section 3.3.
3.8 Notebook-level operations
following mrjo118's simplification in post #65: notebook encryption is not a separate concept. instead, notebooks get "Lock all notes" / "Unlock all notes" context menu options that recursively encrypt or decrypt all child notes.
-
padlock icons appear on individual notes in the note list, not on notebooks
-
no
is_locally_encryptedcolumn on the folder table -
no concept of an "encrypted notebook", just a bulk action on notes
-
this avoids the complexity of dealing with note-move semantics between "encrypted" and "unencrypted" notebooks
3.9 Resource encryption (stretch)
resource management architecture (aligned with mrjo118's solution in post #87, linked from post #39):
data model:
- add
is_locally_encryptedcolumn to theresourcestable and the server item (same nullable pattern as notes:1/0/null). UI indicators (padlock icon, locked placeholder) key off this flag directly - no newreadyStatusvalue needed since option-gated Resource load awaits decrypt explicitly, unlike E2EE's background flow
option-gated Resource save/load:
-
mirrors the note pattern. encrypt resource files using
EncryptionService.encryptFile()(chunked, doesn't load full file into memory), with aJOPLIN_CIPHER:prefix on encrypted content for backwards compatibility -
on load: decrypt to temp-cache (
tempDir/local-vault-cache/), cleanup on app startup + quit (desktop), startup only (mobile) -
migration fallback: where
is_locally_encryptedis undefined, check for theJOPLIN_CIPHER:prefix and update the flag accordingly. if the resource is not encrypted, setis_locally_encrypted = 0in the same save call for optimization -
double encryption/decryption is controlled by the
is_locally_encryptedflag
encryption flow (locking a note):
-
scan resources in the body via
note_resources -
if a resource is shared with other notes → do not encrypt it. surface a warning: "some resources were not encrypted because they are used in other notes. upload a copy if you wish to encrypt them"
-
only encrypt resources exclusively referenced by encrypted notes
-
the
is_locally_encryptedflag on the resource prevents double encryption if the resource is already encrypted by another note
decryption flow (unlocking a note):
-
decrypt all contained resources and clear their
is_locally_encryptedflag -
if a resource is shared with other encrypted notes, it's still decrypted - mrjo118's reasoning: if the resource is publicly available in any note, there's no reason to keep it encrypted in others. in those notes, a visual indicator shows the resource is no longer encrypted
-
present a warning prompt before decrypting: "resources will be decrypted in all notes they appear in" with option to cancel
visual indicators:
-
resources embedded/linked in notes show an indicator when their encryption state doesn't match the note (e.g. unencrypted image in a locked note, or encrypted image in an unlocked note)
-
attachment management screen: lock icon on encrypted resources, password prompt to view them (both desktop and mobile)
-
for encrypted resource in an unencrypted note: show a locked placeholder. tapping it prompts "this resource is locked, do you want to permanently remove encryption for this resource in all locations?" - if yes, enter password and decrypt
bulk operations:
- confirmation prompt for notebook-level "encrypt/decrypt all" actions, since cascading encrypt/decrypt affects mixed resource encryption states
4. Implementation Plan (350 hours / 12 weeks)
Guaranteed core (weeks 1 to 8)
| week | focus | deliverables |
|---|---|---|
| 1-2 | data model + core save/load | migration, NoteEntity schema, SaveOptions/LoadOptions extension, option-gated encrypt/decrypt with body-prefix in Note.save/load, beforeChangeItemJson suppression |
| 3-4 | master key + lock screen | vault master key (SOURCE_LOCAL_VAULT), settings UI, lock screen component (desktop), password entry flow |
| 5-6 | sync + E2EE + search indexing | is_locally_encrypted excluded from E2EE, old-client prefix fallback, search indexing opt-out (SearchEngine.ts), ResourceService.indexNoteResources guard for encrypted notes, integration tests |
| 7-8 | revision safety + conflict testing + leak testing | standalone revision strategy for encrypted notes, cross-device revision flag, revision viewer unlock UI (NoteRevisionViewer), item_changes suppression, conflict safety test matrix, full DB leak tests, verify on desktop and mobile that the unlock note UI does not take priority over any conflicts UI for notes in the conflicts folder |
Stretch goals (weeks 9 to 12)
| week | focus | deliverables |
|---|---|---|
| 9-10 | mobile + bulk notebook actions | mobile lock screen, recursive lock/unlock all notes in notebook, padlock icons in note list |
| 11 | resource encryption + temp-cache | is_locally_encrypted on resources table + server item, option-gated Resource save/load with JOPLIN_CIPHER: prefix, encryptFile/decryptFile integration, temp-cache lifecycle, shared resource detection + warning prompts, visual indicators for mixed encryption states, locked placeholder tap-to-decrypt UX |
| 12 | CLI + Data API + polish | interactive password prompt, 423 Locked on unauthorized API writes, docs |
if i fall behind, the stretch goals get deferred. the guaranteed core (weeks 1 to 8) produces a working, mergeable note-level encryption feature for desktop that can be extended afterwards.
Testing strategy
-
integration tests asserting no plaintext reaches the DB for locked notes (core invariant)
-
unit tests for the option-gated
Note.save/Note.loadpaths -
conflict safety test matrix covering all 8 scenarios in section 3.6
-
sync compatibility tests: old-client prefix fallback, E2EE coexistence
-
revision safety tests: standalone revision creation, cross-device cleanup
-
search indexing tests: encrypted notes excluded from FTS
-
master key lifecycle tests: password change, reset, session expiry
Documentation plan
-
user-facing docs: how to lock/unlock notes, vault password setup, limitations
-
developer docs: option-gated save/load architecture, master key design, sync compatibility
-
migration guide: upgrading from the Secure Notes plugin
-
security considerations: what is and isn't protected, threat model
5. Deliverables
Guaranteed:
-
note-level local encryption with vault password
-
option-gated Note.save/load with body-prefix (
JOPLIN_CIPHER:) architecture -
sync compatibility with old clients via prefix fallback + nullable
is_locally_encrypted -
E2EE coexistence (locally-encrypted body treated as standard string by E2EE layer)
-
standalone revision strategy for encrypted notes + cross-device revision safety
-
beforeChangeItemJson suppression + search indexing opt-out for encrypted notes
-
conflict safety test matrix
-
lock screen UI, settings page, password management (desktop)
-
session cache with configurable timeout
-
integration tests asserting that no plaintext reaches the DB for locked notes
Stretch:
-
mobile lock screen and settings
-
bulk lock/unlock all notes in a notebook
-
resource encryption with option-gated Resource save/load, is_locally_encrypted on resources, shared resource warnings, decrypt prompts, locked placeholder UX, visual indicators on attachment manager
-
CLI interactive vault password support
-
Data API 423 Locked response
-
documentation
6. Availability
-
Timezone: IST (UTC+5:30)
-
Weekly availability: ~30 hours/week during the GSoC period
-
Commitments: summer break from university overlaps with GSoC, no conflicts
-
Contact: keshav.rsk07@gmail.com
Communication plan
-
weekly progress updates on the Joplin forum proposal thread
-
immediate communication on blockers or design changes via forum or direct message
-
PRs submitted incrementally as each week's deliverables are complete
-
reachable via forum DM or proposal thread during IST working hours
AI assistance disclosure
AI tools (Claude, Codex) were used for grammar, formatting, and technical consistency checks. all architectural decisions, implementation design, and code contributions are my own work, developed through direct code study and forum discussion with the Joplin community.