GSoC 2026 Proposal Draft - Idea 7: Local Note Encryption - keshav0479

GSoC 2026 Proposal Draft - Idea 7: Local Note Encryption - keshav0479

Links

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 (null1 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 }:

  1. encrypt body using the vault master key, prepend JOPLIN_CIPHER: prefix, store result back in body, set is_locally_encrypted = 1. ordering note (from mrjo118, post #16): this logic must execute before the changedFields array is computed (Note.ts, L833), otherwise the save model won't detect the body change

  2. set beforeChangeItemJson to null (Note.ts, L849) when is_locally_encrypted is 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 bloat item_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):

  1. retrieve the decrypted vault master key from EncryptionService.decryptedMasterKeys_

  2. strip the JOPLIN_CIPHER: prefix, decrypt the cipher, set the plaintext on the body field of the returned in-memory model. the DB body still holds JOPLIN_CIPHER:... (confirmed in post #22)

  3. 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)

  4. if is_locally_encrypted was null, gated load performs migration immediately (set to 1 if prefixed, else 0) and persists it so future loads skip the prefix check

  5. 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 pass useLocalEncryption: true ever 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:

  1. integration tests that assert no DB write ever contains plaintext for a locked note

  2. careful tracing of all save/load flows during implementation

  3. the editorNoteReloadTimeRequest reload path and similar non-obvious routes are explicitly flagged for testing

search indexing:

  • SearchEngine.ts: both doInitialNoteIndexing_ (L140) and the incremental syncTables_ query (L238) currently select all non-E2EE, non-conflict notes. both need an is_locally_encrypted filter 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): NoteRevisionViewer needs a password gate similar to the editor lock screen. when is_locally_encrypted is 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_encrypted is 1, the note is encrypted. strip prefix on gated load

  • if is_locally_encrypted is null (old client scenario or pre-upgrade), check if body starts with JOPLIN_CIPHER:. if so, treat as encrypted and migrate the flag to 1 immediately

  • if is_locally_encrypted is 0, 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_encrypted is excluded from E2EE encryption (like id and type_) so it's always visible in the clear on the server item

  • the JOPLIN_CIPHER: prefix fallback only runs when is_locally_encrypted is 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:

  1. 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 at createNoteRevision_ 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 modify RevisionService internals.

  2. search indexing: SearchEngine.ts has two indexing queries (doInitialNoteIndexing_ L140, incremental sync L238) that include all non-E2EE notes. core change: add is_locally_encrypted filter 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.

  3. editing transparency: a plugin can't gate Note.load across all internal paths, so it must build its own editor panel. core change: option-gated Note.load lets the existing markdown/rich text editor work transparently with encrypted notes.

  4. item_changes suppression: Note.save's beforeChangeItemJson (L849) captures full cipher diffs on every save due to IV rotation. core change: set to null when is_locally_encrypted is true. internal to Note.save, not plugin-accessible.

  5. resource cleanup: plugin API has onStart but no onExit hook. decrypted temp files need cleanup on app quit (desktop) and startup sweep (mobile). core lifecycle management can handle this across platforms.

  6. 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.

  7. orphaned resource protection: the background indexer (ResourceService.indexNoteResources) parses note.body to extract resource IDs. for locally encrypted notes, the ciphertext body yields no IDs, which would mark all associated resources as is_associated = 0, eventually causing NoteResources.orphanResources() (NoteResource.ts, L159) to flag them for deletion. core change: alter NoteResources.orphanResources() to JOIN the resources table and exclude resources where is_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_encrypted column 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_encrypted column to the resources table and the server item (same nullable pattern as notes: 1/0/null). UI indicators (padlock icon, locked placeholder) key off this flag directly - no new readyStatus value 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 a JOPLIN_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_encrypted is undefined, check for the JOPLIN_CIPHER: prefix and update the flag accordingly. if the resource is not encrypted, set is_locally_encrypted = 0 in the same save call for optimization

  • double encryption/decryption is controlled by the is_locally_encrypted flag

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_encrypted flag 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_encrypted flag

  • 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.load paths

  • 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.

1 Like

Hello, we've recently updated the template for GSoC draft proposals. Please update your post as described here:

Thanks for the template pointer, i've updated the first post to follow it.

A couple of design questions as i work through this:

  1. for the body-field challenge, i'm weighing two approaches:
    • keep the current option-gated body approach and enforce safety through integration tests
    • use a separate transient property for decrypted content in editor/viewer paths, so plaintext never sits on the serializable body field at all

the second option is cleaner from a safety perspective but touches more of the editor layer. given the goal of keeping core impact low, which direction would you lean toward?

  1. for the JOPLIN_CIPHER: sync fallback, i'm assuming full conflict resolution for encrypted notes should stay out of scope here since there's a separate GSoC project for that. this project should focus on conflict safety (no data loss) rather than conflict UX. is that the right boundary?

I’d lean more towards the second option but I’m not sure. If you could do some investigation detailing what changes are required, maybe I would have a better idea. You would need to consider whether this needs to impact the cli and api as well

I’d say so yes. Encrypted notes could be created externally and I think any conflict mechanism implemented should not corrupt such notes

You will still need to include testing of conflict scenarios, even if direct changes are not required. So I’d say you should mention this in the proposal. For example in handleConflictAction.ts, this will duplicate the note using Note.load and then clear some of the fields before saving as a new note. So that would be important to make sure it works. It also looks like conflicting resources are handled there too

Thanks, that helps a lot.

i traced a few paths for option 2. on desktop, initNoteState() in useFormNote.ts pulls n.body into FormNote. for encrypted notes, editor/viewer paths could read from a transient field instead of the serializable body. since FormNote is separate from NoteEntity, this stays mostly in the UI layer.

mobile looks similar, Note.tsx relies on note.body across edit/view/autosave, so it would need the same pattern.

for cli/api, my current thinking is to return body="" with is_locally_encrypted by default, only decrypt on explicit unlock, and reject writes to locked notes unless unlocked first.

one open question: should the transient field live only on UI types like FormNote, or also on NoteEntity with strict exclusion from persistence? UI-only seems lower impact but want to make sure cli/api stays clean.

yeah that makes sense, i'll trace through handleConflictAction.ts to make sure the note duplication path handles is_locally_encrypted and local_cipher_text correctly, and that the transient field doesn't leak into the duplicate. i'll cover resource conflicts there too and add conflict scenario testing to the proposal.

for encrypted notes, editor/viewer paths could read from a transient field instead of the serializable body

I think it would have to be for all notes using the transient field, not just for encrypted ones? The React / React Native component to display the note contents would have to map to just one variable to work. Unless it would work to set this value to a variable with a conditional underlying value? Then you can just use the local_cipher_text / body conditionally instead of making a new transient field. I can’t remember whether React and React Native would allow you to do that without causing re-rendering issues. Maybe something you could test?

If that works, you make those changes to the ui code and instead of an option gated Note.save and Note.load, you could use the local_cipher_text field to store the value in both encrypted and decrypted form, and where is_locally_encrypted is set to true, then you encrypt / decrypt the value into the same field inside Note load / save (skipping decryption in the load route, when encryption_applied is undefined or true).

Unless it would work to set this value to a variable with a conditional underlying value

I guess the issue with that might be when you toggle encryption on the note, it would need to switch which column it is writing to. You could drive the value by a state variable instead, but then you would have to make sure in all code paths in the ui the value is kept in sync, which could be messy. Maybe syncing to a transient field at a lower level would be cleaner, but it would still have to be outside of the Note save / load functions for it to switch between target fields when you toggle the encryption

yeah the toggle case is a good point. switching which column the UI writes to mid-session would be messy to keep in sync.

I think a thin layer outside Note.save/Note.load is the cleanest approach. something that:

  • after load: populates a transient display field from the right source (body for normal notes, decrypted local_cipher_text for encrypted ones)
  • before save: writes the display field back to the correct column based on is_locally_encrypted

that keeps Note.save/Note.load untouched, which also avoids the partial-save risk i was tracing. mobile's saveOneProperty and cli's sparse saves would pass through Note.save without touching the encryption layer, since it sits above them.

the encrypt/decrypt step would only run in paths that actually load full note content for editing (desktop editor, mobile note screen).

I'll prototype this and test the toggle case (encrypting and decrypting the same note in one session) to see where the seams are. i'll also keep the conflict/resource scenario testing in scope as discussed.

Hi,

I'm Aravind, author of Secure Notes Plugin. Sorry, I didn't go through complete Draft. But here are a few things I would like to add:

  1. Some people prefer to have different passwords for different notes, not single master vault password (Survey few people about which model, proceed with the majority).
  2. Password caching for the session (may with timeout configurable in settings, etc).
  3. @mrjo118 has already patched revisions bug so, you can simply use it I guess. And small note, flush the revision only once i.e. after encryption, don't flush it after decryption as that revision might be help full in case of decrypt corruption.
  4. On the fly encryption (if possible): issue.
1 Like

I think I may have been getting confused when looking at a solution to the sync with old client issue.

I think the original option gated Note save / load approach would actually work to the same effect as a thin layer outside of it, but without requiring code duplication. The way you described how it would work in the option gated section in your proposal is clearer than in the proposal of the other candidate, and I think aside from a couple of exceptions, that approach works overall, providing that you consider all save and load flows for the relevant UI which enables the option. A particular example to watch out for is in Note.tsx there is some logic which reloads the note when editorNoteReloadTimeRequest has changed, so missing enabling the option gate for that route could result in corruption of the note, due to it not being decrypted on load.

In terms of the exceptions:

  1. When serialising / deserialising the server item, loading / saving of the note will not use option gated paths, but instead there should be a separate function to move copy transfer the encrypted cipher between the local_encryption_cipher and and the body on the server item, and additionally the logic to add or remove the JOPLIN_CIPHER: prefix and toggle the is_locally_encrypted flag when appropriate for a server item from an old client
  2. The other exception is with the conflict resolution. I initially thought this did not need to be handled, but actually there are some additional considerations here which I’ll cover later

Would you agree that this approach is actually fine?

I still haven’t fully figured out the conflict resolution considerations, but had another thought about the fallback logic for syncing with old clients.

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

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

Yes, i agree the option-gated approach is the right direction. the thin-layer discussion was useful for working through edge cases, but your point about duplication makes sense.

Good catch on the editorNoteReloadTimeRequest path in Note.tsx. i traced it and confirmed: reloadNoteAndUpdateRefreshKey() calls shared.reloadNote() which goes through Note.load without any option gate. if the note is encrypted and this reload triggers, it could load ciphertext without decrypting, and a later edit could write that back incorrectly. that's exactly the kind of path that needs the gate.

on the two exceptions:

  1. for sync serialize/deserialize, agreed. a separate mapping function to handle local_cipher_text to body transfer and the JOPLIN_CIPHER: prefix makes sense, independent from the option-gated save/load.
  2. for conflict handling, happy to wait for your thoughts on the specific considerations. i'll keep tracing handleConflictAction in the meantime.

on the double-encryption point, skipping E2EE on body for the server-item mapping when is_locally_encrypted is true makes sense since it's already encrypted locally. having is_locally_encrypted excluded from E2EE encryption (like id and type_) so it's always visible in the clear is clean, and the JOPLIN_CIPHER: prefix fallback only runs when is_locally_encrypted is absent from an old client.

i'll update the first post with this as the current design direction and flag the editorNoteReloadTimeRequest path as a specific test case.

1 Like

Thanks for input Aravind, really useful perspective from the plugin side.

On password model, my current core-scope plan is a single vault master key, mainly to keep key management and UX manageable for this project size. per-note passwords could be a future extension.

good point on revision timing. my current direction is to flush around encryption events and avoid unnecessary flushes on decryption paths, but i'll validate the exact behavior with tests and mentor feedback so we dont create new leak or recovery problems.

on on-the-fly editing, your issue #3 is a good reference for the corruption risks. the approach we've converged on with mrjo (option-gated Note.save/Note.load with encrypt/decrypt inside the gate) keeps data safety first while still making it reasonably transparent to the user.

also agreed on revisions scope: mrjo's patch covers a specific revision-history path, but i still need to guard other leak vectors like search index, item_changes, and sync payload.
i've folded session cache timeout, revision behavior rules, and conflict test scenarios into the first post update.

1 Like

I read through your updated proposal and have some more comments:

on subsequent saves of already-locked notes: no revision history deletion needed (body is already empty, revisions see nothing new)

For this point, can you make it clear that an actual code change is needed here to suppress creation of revisions for locally encrypted notes?

skip populating beforeChangeItemJson (set to null) when is_locally_encrypted is true, so plaintext doesn't leak through item_changes.before_change_item (Note.ts, L821-831)

Regarding item changes, I initially thought it was necessary to explicitly suppress the beforeChangeItemJson being set within Note.save, but actually I realise it is not necessary to do so, because the inner Note.load call to set the oldNote value would not use the option gate, so therefore it would always match old body to new body which will both be an empty string, providing that the body is tranferred to the local_cipher_text field first. However it is important that the added logic (including transferring the local_cipher_text value) when useLocalEncryption = true, is added before the changedFields array is set, otherwise the save model will not detect any change on the local_cipher_change field and wont save it in the db

if is_locally_encrypted field is present, use it directly

Note that it still needs to strip the prefix in this case

revision leak: mrjo118 identified that the plugin's approach triggers the revision system with plaintext. even using Laurent's suggested joplin.views.editors API to bypass revisions during editing, the initial encryption still creates a revision, and item_changes.before_change_item is completely outside plugin reach.

I think you can remove this ’revision leak’ point, as it is a adressable when we have a Joplin 3.6 stable release. Instead, replace it with a ‘no revision control’ point. While existing unencrypted revisions can be deleted upon encryption of a note, when using a plugin, it wont be possible to opt out of the revision service. In particular, changes to encrypted notes could create large diffs as revisions, due to the contents being a cipher instead of plain text.

Additionally a significant point you could add is ‘no sync control. Probably the biggest disadvantage of the plugin approach is being unable to bypass the sync when decrypting the contents for editing, unless you make your own editor plugin for this purpose. But if you write your own editor plugin, it is going to offer a significantly cut down experience, as you would need to replicate the markdown and or rich text editor to provide a full editing experience.

Also, please change the timeline to a 350 hour project, as 175 hours is just too short. The project plan is still for 12 weeks though.

1 Like

Thanks for the detailed review, incorporated all the points into the first post:

  1. save path ordering: moved the encrypt + transfer logic before changedFields is computed so the save model detects the local_cipher_text change. good catch, i hadn't considered the field-change detection ordering.

  2. beforeChangeItemJson: removed the explicit suppression step. the inner Note.load for oldNote doesn't use the option gate, so body="" matches on both sides and no diff gets recorded. cleaner than what i had before.

  3. revision suppression: updated to make clear that an actual code change to RevisionService is needed to skip creating revisions for locally encrypted notes, not just relying on body being empty.

  4. prefix stripping: added that the JOPLIN_CIPHER: prefix still needs to be stripped even when is_locally_encrypted is present on download.

  5. plugin section: replaced revision leak with no revision control (plugin can't opt out of RevisionService, creates large cipher diffs) and added no sync control as a new point. also updated to 350 hours.

one question on the revision suppression: would the cleanest approach be to check is_locally_encrypted inside RevisionService and skip revision creation for those notes, or should we prefer a more general opt-out flag passed through the save options?

Just using is_locally_encrypted to opt out is fine

makes sense, i'll use is_locally_encrypted directly in RevisionService to skip revision creation.

also, i noticed is_locally_encrypted should be nullable (1 / 0 / null) rather than DEFAULT 0, so we can distinguish "explicitly not encrypted" from "never set by this client". the null state would be the trigger for the prefix fallback check on load, while 0 skips it entirely. does that match what you had in mind?

Yes that’s correct, it should be nullable / optional in both the database in the server item.

I realise another problem though, which is if you update an existing client which has had encrypted notes from a newer client already synced with it, the encrypted notes would be in the wrong column in the sqlite db of the client which was upgraded and the new is_locally_encrypted column would initialise to null.

The proposal from the other candidate has gone down a slightly different path whereby there is no separate local_cipher_text column, but the cipher is stored in the body column with the prefix, and the fallback logic applies on loading the note. For that implementation, there would not be an issue if an old client with encrypted data from a new client is then upgraded. I think due to this migration issue, that this solution would actually be a better choice, otherwise you’ll then have to duplicate the fallback detection logic in Note load.

Note that for that solution, you do additionally need to suppress the beforeChangeItemJson explicitly when is_locally_encrypted is true, and you also have to opt out with the search indexing.