Case Study
Encrypting an AI Journal, and Deciding What Stays Readable
Encryption on the device for a journal whose AI companion still has to remember, from the threat model in April to the one-line summary sealed in October.
TL;DR
Duskglow encrypts each journal entry on the phone with AES-256 before it is stored, with the key in a private app-only folder in the user's own Google Drive. The cipher was the smallest decision. Feature after feature made another copy of the user's words, a draft, a summary, a notification, and once recovery existed a copy of the key, and each one needed its own answer. Between April and October I tested the claims against the deployed system, encrypted every existing journal, each at its next open, in a release that could not be rolled back, added a recovery code, moved search onto the phone, and sealed a summary field that could carry names. Some things stay readable on purpose, the page names the main ones, and two of my posts got correction notes on the name-filter claim along the way.
The Problem
People write things in a journal they would not say out loud. In Duskglow they write them to Luna, an AI companion, at the end of the day, and Luna answers. That second part is what makes the privacy design hard. A journal nobody else reads can encrypt everything and stop there. A journal with a companion that remembers your week has to let something be read, by someone, for a reason.
So the question I was answering was never “should entries be encrypted.” It was which copies of a person's words exist, who can read each one, and whether I could say all of that in plain language without overstating it. A saved entry is one copy. The draft you leave half-written is another. So is the summary on each journal card, the reflection Luna writes about the entry, the longer summary Luna reads to remember you, the reminder that arrives in the morning, and, once recovery exists, a copy of the key itself.
The stakes run in two directions. If the design is weak, a person's private writing is exposed. If the public description is stronger than the design, I've made a promise the system doesn't keep, about the most sensitive thing the app holds. I was building alone, without a security team to review either one.
Approach
Test the claim against what is deployed.
In April I sat down to add one column to an admin dashboard showing each user's encryption status. Thirty minutes of work. It took three hours, because pressure-testing the dashboard meant pressure-testing my own descriptions of the system, and one of them was narrower than it sounded.
Encryption protects what is already stored. It does nothing about someone with database access changing the settings that decide how the next entry gets saved. Nothing in the architecture would announce that kind of change, so the user needed a way to notice it. The app now checks the key it loads against a fingerprint of the key the account was set up with, a one-way hash kept in the database, and refuses to continue on a mismatch. That check is the part encryption could never do on its own. The April post has the full pressure test.
Two months later I put the privacy policy off on purpose, until the encryption stopped moving (why). A policy written ahead of the design would have described a system that didn't exist yet, and I'd have rewritten it the moment the architecture landed.
Put the key with the user, and plan for losing it.
The key is created on the phone and stored in a private, app-only folder in the user's own Google Drive, with a copy cached on each device. The database holds the fingerprint, not the key itself, and, since recovery shipped in September, an encrypted copy that the recovery code unlocks.
Holding the key there moves the hard part to the day it goes missing. A key that is provably gone earns a fresh start, while one the app simply can't reach right now earns a retry, and never a quiet new key that would strand every entry written under the old one (the key design). A guard stops the app and asks whenever encrypted data already exists, rather than making a new key (the third option).
Drafts got the same rule. The early app saved a half-written entry in the browser in plain text, an old convenience. I retired it instead of patching it, and drafts are now saved on the server in encrypted form only, with the database refusing a draft that isn't (why I rebuilt it).
An irreversible migration, tested outward-in.
New entries were one thing. The entries already in the database were plain text, and encrypting them in place has no undo. Reverting the code afterward unscrambles nothing, because the code was never what held the words.
So the recovery had to exist before the change ran. The upgrade screen offers a backup first and recommends it firmly, as a document a person can read and a file that can restore everything, saved together in one tap. The test order ran from the least real to the most real: synthetic accounts against a copy of the production database, then a throwaway account on the real sign-in and the real Drive storage, then my own journal last, after I had backed it up.
Partway through, I was one tap from encrypting my real journal on a preview build. The screen didn't show which account was signed in, and I assumed it was the test one. I checked the stored session before tapping, and it wasn't (the migration). On an irreversible step, the interface looks exactly the same whether you are about to do the right thing or the wrong one.
Recovery, and the sentence it cost.
Until September, a lost key meant a lost journal, and I had told my testers so myself. The recovery code closes that gap. The app generates a one-time code on the phone, shows it once, and encrypts a copy of the existing key under it. We keep that encrypted copy, and the code is what unlocks it. The code itself is never sent to us or stored by Duskglow.
I rejected a passphrase the user invents, because people pick weak ones, and a weak secret guarding a stored copy is a patient attacker's best case. I also rejected a recovery file with no server copy, which is purer and worse, because the person who needs recovery is by definition the person who lost track of something. The honest cost was one sentence of my own description. “The key never touches our servers” became “we keep an encrypted copy the code unlocks” (the recovery post). The second sentence is less impressive, and it's true.
The June account mistake came back here, and this time it went through: I enrolled my real account when I meant the test one. It was harmless, because enrolment only adds an encrypted copy after checking the key against the fingerprint, and the check I was relying on, the account name and picture, could not tell the two apart.
Every new feature can be a new copy.
After the migration, the encryption itself barely changed. What kept coming up was each new feature making a fresh copy of the words.
The reflection Luna writes about an entry went first, stored encrypted like the entry itself. Reminders were the next test. The evening reminder carries none of the user's words (what a notification may know). The morning one can show the intention a person set the night before, so the phone seals it before it leaves, and it opens only in that browser. Search came after. For three months I carried a note saying search was a hard fork between letting the model read what it needed and keeping search dumb. It turned out search could run on the phone, over the entries the history screen already decrypts, and the earlier server search over summaries was retired. Nothing is sent until the user taps to summarize the matches, and that summary is shown once and not stored (choosing how search works).
The field I had misdescribed.
In October a claims audit of my own posts sent me back to read what each readable field actually held. The one-line summary on each journal card was readable, and I had written in April that the readable summaries filtered names out. They didn't. The longer summary Luna reads had been given an instruction in June to leave names out. The short one never got it, and a June test had already found a planted name and employer in it, word for word.
Before designing anything, I asked whether the change was necessary. On the narrow reading it wasn't: no law I know of required it, and the privacy policy already disclosed the field as readable. Then I measured instead of arguing, and the two technical reasons for a cheaper stopgap disappeared. I chose the full fix (the decision). It shipped as two releases, the screens learning to read the encrypted form before any phone wrote it, so a revert of the second release would leave every card still showing its line. Existing lines were sealed quietly at each account's next open, and the shape got a test I had not planned: my own account was still signed in on a preview, the unshipped build sealed my lines, and production went on showing every card (the two releases). Two of my published posts had the old claim, and both now carry a dated correction note.
Architecture
The copies of a user's words the app stores, and where each stands today.
| Copy | How |
|---|---|
| The saved conversation | AES-256 on the phone, with the user's key; the database stores the encrypted form |
| The reflection Luna writes about an entry | Written by the model, sealed on the phone, decrypted on the phone for the home screen |
| The in-progress draft | Saved on the server in encrypted form only |
| The one-line summary on each card | Sealed with the entry since October; older lines converted at the account's next open, so an account that has not opened since keeps its readable lines until it does |
| The morning reminder | Sealed on the phone with that browser's own keys, so it opens only in that browser; the evening reminder carries none of the user's words |
| The recovery copy of the key | Encrypted under a one-time code that is shown once and never sent to us |
Search sits beside this table rather than in it, because it makes no stored copy. It runs on the phone over what the app has already decrypted, and sends the matched entries to the model only when the user asks for a summary of them. The backup and the export are the other readable copies: made on the phone at the user's request, and held by the user, not by Duskglow.
| What | Why |
|---|---|
| Each message while you chat | It passes through the server in readable form on its way to the AI model, which has to read it to answer; what the model's provider keeps is in the privacy policy |
| The long summary of each entry | Luna reads it to remember your recent days; it is written under an instruction to leave names out, a reduction rather than a guarantee |
| The mood, name, email and streak, with the reminder settings and the usage data | The account and the app's screens run on them |
| A fingerprint of the key | A one-way hash the app checks the key against, so a changed key record shows up on the device |
I have direct access to the database, for running and debugging the service, and I can read the stored items in the second table. For the conversations and the one-line summaries, what the database holds is the encrypted form. The privacy policy says the same.
There are gaps, and they belong on this page too. Recovery covers a lost key file, not a lost Google account. If both the key file and the recovery code are gone, the encrypted entries can't be restored by the user or by me, and the app offers to start fresh. Deleted data can sit in the database host's daily backup for up to seven days. And deleting a Duskglow account doesn't delete the key file: it stays in the app-only folder in the user's Drive, and Duskglow doesn't remove it; a cached copy can also stay in the browser's storage on each device until it is cleared.
What I'd Do Differently
I'd list every copy of the words on the first day. The one-line summary was readable for six months because it was built for a journal card, and nobody carried the long summary's rule back to it. A field derived from sensitive text takes the privacy of the least careful reader it was built for. A list of copies, kept next to the design and updated with each feature, would have put the short summary under the same rule in June, when the long one got it.
I'd write each public sentence from the mechanism, not the intent. “Filtered names out” described what I meant the summaries to do. “The key never touches our servers” described the design before recovery existed. Both read well. One had to be corrected in public later, and the other was retired the day recovery shipped.
A check that tells two accounts apart, from the start. I made the same account mistake three times. In June I caught it one tap early. In September and again in October it went through, each time on a step that had been made safe to get wrong. The check I had given myself, the account name and picture, couldn't tell my real account from my test account because both are me. Being more careful was the weakest fix on offer. Safe steps and a check that actually discriminates were the two that worked.
Impact
- Every saved entry encrypted on the phone, with each existing journal converted at its next open, in a release that could not be rolled back, behind a backup offered first.
- A recovery path: an encrypted copy of the key that the user's code unlocks.
- Five stored copies encrypted on the phone, the conversation and four things made from it, plus the recovery copy of the key, and every readable item named with its reason in the privacy policy.
- Two claims corrected: the name-filter sentence, with dated notes on the two posts that made it, and the key-custody sentence, softened when recovery shipped.
No outside security review of this work had been done at this writing, and the page describes what was built rather than certifying it. The recovery gap for a lost Google account is still open, and the long summary's name rule is still an instruction to a model, which is why it sits in the readable table.
Principles
Encryption is a claim about stored data. Who can change the settings around it is a separate question, and the answer to that one is detection.
Count every copy of the words. Each feature that touches them can make another, and each one needs its own answer.
Build the recovery before the change. For a step that can't be undone, the only recovery that counts is the copy made before it ran.
Say what stays readable. A list of what is protected means little without the list of what isn't, and the second one is where a reader decides whether to trust the first.