Aug 02, 2026 Joplin

Local note encryption

Notes from my GSoC work with Joplin on local note encryption.

A running note from my Google Summer of Code project with Joplin.

Joplin already protects notes while they sync. This project adds a separate way to lock a note on the device too.

I am starting with individual note locking, then making the surrounding paths such as history, search, attachments, mobile, CLI, and the API handle locked notes safely.

Aug 2#

Worked through the guardrails feedback. API listings now skip locked notes entirely, convert to Markdown works once the session is unlocked, and the CLI refuses to flip the lock flag with a proper error instead of a stack trace. The revisions branch got its one-line change too, plain history now restarts when encryption comes off a note.

Aug 1#

Pushed the mobile review fixes, the undecryptable note screen and the new menu icons. Right after the push the power went out and the laptop died with it. One commit didn’t survive, though I only found that out the next day.

July 31#

The mobile lock UI and revisions PRs both came back reviewed the same morning. Split the flaky test fix into its own PR and it merged the same evening. Guardrails went up on the main repo too, then straight into draft with a list of API questions to settle.

July 30#

Moved the mobile lock UI over to the main repo, fresh branch off dev with the reviewed work as one commit.

July 28#

The desktop lock UI merged. Biggest piece of the project so far.

History restore and delete now grey out while a note is waiting for its password, and every dialog that asks a question answers in Yes and No now.

July 27#

The mobile config screen merged overnight. Spent the day on Laurent’s review of the desktop PR, the real fixes were the enable flow rerunning itself from the config screen and a read-only flag that was global when it should have been per window.

July 24#

The project got extended to early November on the portal. Exams eat mid August, so the runway helps.

July 17#

The lock UI went up on the main repo. The mobile config screen also came back as a normal section, quick rebuild now that the desktop pattern it needed is merged.

July 16#

The config screen merged overnight. First visible piece of this project in the main app.

Barely slept for it though. Argentina England semis at four in the morning, Messi still doing Messi things. Back to back, lessgo.

July 15#

Another review round on the lock UI, and some small polish on the config screen.

July 13#

More review on how locked notes load. Also filed an issue for a small gap I noticed in the existing E2EE reset flow.

July 12#

Reworked the locked note loading so the panel shows without creating any editor state. Simpler and it fixed some flashing. Also got the mobile side of locked notes working locally.

July 11#

A simplify wave from mrjo on both PRs, mostly deleting my defensive code. Less code, better code.

July 10#

Pushed both reworks, the config screen as a normal Options section and the full review round on the lock UI PR. Both are back in review now.

July 9#

The lock UI PR went up just after midnight, with demo videos this time. Reviews came in the same day, Laurent on the config screen and mrjo with the biggest review of the project so far. Most of it was me hardening cases we had already accepted in the risk assessment.

July 8#

Squashed the desktop lock UI into one commit. Also submitted my midterm eval.

July 7#

The lock UI works end to end locally now. Locked panel, manual lock, and the auto lock setting actually doing something.

July 6#

Replanned the remaining weeks with mrjo. Bulk encrypt/decrypt is in, resource encryption is a stretch goal now. Core solid first.

July 5#

The session PR merged overnight, and both config screen PRs went up in the main repo, desktop and mobile.

July 3#

Another quick review pass on the settings screen, just labels and spacing this time.

July 2#

Reworked the settings screen after the first review. Dropped the action dropdown for a plain reset link that flips into reset mode. Also made unlock throw when it fails instead of quietly returning false.

July 1#

More key scoping, on the encrypt side now. The main bit was making the shared encryptor fail closed when the key has rotated under it.

June 29#

Tightened where the decrypted key lives. It used to stay around after unlock, now it only goes to the encrypt or decrypt call that needs it.

June 28#

Split the note lock code in two, the key on one side and the session state on the other. Also started the desktop settings screen.

June 27#

Review fixes on the session PR, mostly cleaning up the key state after an export finishes.

June 26#

Another proposal cleanup day.

The old unlock timeout idea is gone now. Explicit Unlock encrypted notes / Lock encrypted notes feels easier to reason about, and there will be an optional Auto lock when switching note setting for people who want that behaviour.

June 25#

Export came up today, and it was a good catch.

If a backup keeps notes locked, restoring it later can depend on a key that might not exist anymore. So exports should write them out as normal unlocked notes after decrypting them.

June 24#

The search/indexing PR merged today.

This one also ended up covering the new AI embedding index, not just normal search. Nice to have both following the same rule.

June 23#

Good day for small PRs. The attachment indexing PR merged, and the mobile feature-flag fix merged too:

Attachment indexing PR

Mobile feature-flag fix

The feature-flag fix was tiny, but useful. A private flag was showing on mobile, so mobile now filters settings the same way desktop does.

June 22#

Went back through the plan with the repo in its current state. Since PR1 and PR2 are merged now, the next PRs can be smaller and independent, which should make review less tangled.

June 21#

Spent time on ResourceService review fixes. Mostly small cleanup around extracted_resource_ids.

June 20#

Kept the attachment indexing PR moving through review. The main thing was making sure saved resource IDs still go through Joplin’s existing validation instead of trusting them directly.

June 19#

Opened the third PR today. A locked note’s body is ciphertext, so the normal resource scanner cannot see its attachment links. The change rebuilds those associations from the resource ids saved before encryption.

Also tested the same path after sync with two profiles.

June 18#

The backend PR merged today. One last fix made the decrypt helper return a copy instead of changing the loaded note object.

Then finished the ResourceService change and its manual test for the next PR.

June 16#

Answered another backend review pass. Clarified why useNoteLock is opt-in: normal loads return the persisted body, which is ciphertext for a locked note, and only unlock-aware callers should request plaintext.

June 14#

Applied another set of backend review fixes. Replaced the local resource-id regex with Joplin’s existing isItemId helper and tightened the missing body and lock-state checks.

June 13#

Posted the week 3 forum update. The backend PR is now open in the main repo, targeting dev like a normal PR instead of sitting stacked on a fork branch. A week of review and cleanup, and the whole path feels much more solid than it did last Friday.

June 12#

Last round of review on the backend draft: five small comments, all fair. Applied them, restacked the branch onto the freshly merged dev so the diff is only this week’s slice, and ran the whole checklist again.

June 11#

The schema PR merged. First gsoc code in Joplin’s dev branch. Also pushed the reworked backend branch: the new naming everywhere, and the gated save stripped of its clever recovery logic.

June 10#

Simplified the gated save path after a review pass. My first version tried to recover from weird partial states, like a save missing the lock flag. But a gated save missing the lock state is really a bug in the caller, so it should fail loudly there instead of quietly saving something wrong. Less code, clearer rules.

June 9#

The schema PR got reviewed, and the naming changed for the better. is_locally_encrypted is gone, because “local” stops meaning anything once a note syncs somewhere. It is just is_locked now. That pulled the backend naming along with it: noteLock, NoteLockKey, NoteLockService. I like it more. Locked is the feature, encryption is just how it works underneath.

June 6#

Pushed the structure changes to the week 2 draft. Also did the E2EE manual pass with two clean profiles: encrypted note and attachment one way, edit back the other way. Good kind of boring.

June 5#

Posted the week 2 forum update. This week was mostly backend work: local key path, gated save/load, and making sure history does not quietly keep old readable copies after a note is locked.

June 4#

Kept the week 2 draft moving on the fork. Branch chaining is useful, but I can already see why keeping only one chained PR open matters.

June 3#

Opened the tracking issue and put the backend PR up as a draft, stacked on the schema work so the diff only shows this slice. Early feedback while it is still small.

Still all backend. No lock button yet, nothing a user can press. That comes once this path is solid underneath.

June 2#

Spent the day trying to break it. The cases I care about are the quiet ones: a damaged or half-written locked note should fail closed and leave the note and the database untouched, not save something broken.

Also the history. When a note first gets locked, the old plaintext revisions have to go, or the thing you just locked is still sitting there in its edit history. Easy to forget, bad to miss.

June 1#

Pulled the week 2 work into one backend PR instead of splitting it: the encryption service key path, the local key wrapper, and the gated note load and save. They only really make sense together.

The schema PR has not merged yet, so this one stacks on top of it as a draft. Building on a branch that is not in yet is a little awkward, but it keeps the diff small and honest about what depends on what.

May 30#

Took the schema testing a bit deeper, following a suggestion from mrjo. Checked that revisions still get created after the new column, and looked at the raw items on disk with E2EE on. The note body is an unreadable blob while is_locally_encrypted stays a plain 0 next to it.

Good way to learn where to actually look when testing a different part of the app.

May 29#

Manual pass of the PR on desktop. Two profiles syncing through a file system target, with a notebook, a few notes and an image, round-tripping both ways.

Getting the dev build to cooperate took longer than the test itself. Also posted my first weekly update on the forum, planning to do one every Friday.

May 28#

The first PR is up now. It is just the schema and sync metadata layer, which is exactly where this had to start.

It is a small start, but it feels good to have the first piece out in the open.

May 27#

Spent the day studying the schema and sync paths properly before opening the PR. A small database field does not stay small for long in an app like Joplin. It shows up in local tables, generated types, sync serialization, E2EE metadata, API responses, and server tests.

Also cleared up one detail for the upcoming encryption service work. The local-note wrapper needs the decrypted key and the key id together, but it does not need to carry the whole master key object around.

May 26#

Spent the day getting the clean dev repo into a working shape and turning the plan into an actual PR order. The goal is still small reviewable pieces, not one giant feature dump.

Also started keeping project learning notes alongside the code. I want to understand the Joplin paths as I touch them, not just move files around and hope the tests are kind.

May 25#

Coding starts today. The first thing is not the lock screen, even though that is the tempting part. It is the plumbing: schema and sync fields, old sync items mapping cleanly to the new shape, and the local note encryption key storage path.

Boring on purpose. I would rather get the data shape right before there are buttons people can press.

May 24#

Finished the last planning pass across the proposal, weekly plan, and risk assessment. The biggest cleanup was the key story. Local note encryption now has its own key path, separate from the normal sync E2EE master keys, with the encrypted key stored separately and only the decrypted key kept in memory after unlock.

Also made the feature guard more concrete: during development, the local encryption entry points should only be enabled in dev mode, so prerelease users do not run into a half-built lock feature by accident.

Also updated the diagrams so they match the text again.

May 23#

Spent most of the day on another proposal/risk pass. The annoying bug today was attachments. If a locked note hides its markdown body, Joplin can no longer see the resource links inside it, and orphan cleanup might later delete an attachment that is still used by that locked note.

The fix we settled on is pretty small: save the linked resource ids before encrypting the body, then let the resource indexer use those ids later. Small column, but it avoids a nasty data loss case.

May 22#

Went through another mentor review pass. I had one thing wrong: the lock flag should stay as cleartext sync metadata, not be hidden inside the E2EE payload. That way a synced client knows a note is locked as soon as it downloads the item.

Also shuffled a few things into better places. Null-byte failures go with conflict/leak testing, revision cleanup belongs on the save/sync transition, and plugin/API paths should fail closed instead of trying to be clever.

May 21#

Updated the proposal from the review and finished the week by week plan, then sent both off. Now starting a risk assessment for the project. This one touches a lot of the app, and it is people’s actual notes, so it is worth writing down plainly what could break, who it would hit, and how we would even know. Better to stare at the worst case now than find it later.

May 20#

Read a Joplin PR that landed about saving notes that contain a null byte. It made something click. Taking encryption off a note is not just decrypt and done, the save right after can still fail. If it does, the note and the editor cannot be left half changed. That kind of quiet data loss is exactly what I want to catch before it ever ships, so it goes straight onto the testing list.

May 19#

A week until coding starts. I am turning the plan into a week by week version, walking the proposal section by section so nothing quietly falls off. Re-reading your own work is humbling. You always find the parts you waved past the first time.

May 15#

Settled how I want to contribute. Small PRs, straight to dev. The one thing to front-load is the schema work, all of it in one go, so a prerelease never ends up holding half a schema. Easy to miss, painful to unpick later.

May 14#

First intro call today. Laurent, mrjo, the other mentors, and the selected students were all there.

Nice to finally see faces. I also had a 1-1 with mrjo after the call. He was very kind, and I left feeling glad this is the group I get to learn with.

May 7#

The sync side got simpler today, which is the kind of small thing that makes a hard week feel less hard. A full vault/private-notebook model is too much for v1. It can wait.

May 5#

Spent the day re-reading the proposal and the old forum threads, walking through the parts of the codebase this will touch. Mobile, CLI, and API are easy to forget when you live in the desktop app. They shouldn’t be.

Apr 30#

Got selected.

Happy. A little stunned. Now to actually do the work.