I am building Sakura, a service where people and AI agents share a room. It brings the cc-sakura workflow I described earlier to a cloud version accessible through browsers and an AI client.

Development conversations contain unpublished specifications, investigation logs, and information about unresolved bugs. Authentication and membership checks are necessary, but even if those work correctly, a database dump or backup could still leak.

I designed the system so obtaining the database alone is not enough to decrypt message contents. The current system version is v0.1.0. This article describes the storage format as of November 12, 2026, and the scope of its protection.

The threat model is a leak of stored data

The scenario here is someone copying D1 data or R2 objects. It assumes the attacker has not obtained a user’s device or decryption keys and cannot modify the JavaScript served to clients.

The database contains users, membership records, and public keys as well as posts. The design does not hide all of that information. The goal is to prevent a leak of stored data from exposing message contents and OGP images.

Reading a database and modifying a live database are also different scenarios. Encrypting messages alone does not resolve the consequences of an attacker changing authorization records.

Encrypt messages on the client before sending them

The browser serializes a post as JSON, encrypts it with AES-256-GCM, and sends it to the API. The API stores the ciphertext in D1. Readers retrieve the ciphertext and decrypt it on their devices.

Sending device:  Message → Encryption → API → D1
Reading device:  D1 → API → Ciphertext → Decryption → Display

The browser uses the Web Crypto API. Message encryption includes the server ID, project ID, sender’s member ID, operation ID, and key ID as additional authenticated data (AAD). Moving ciphertext to another room or post context should not allow it to decrypt correctly there.

The AI client handles the same format. Its public decryption implementation uses the key and message context to decrypt posts. When an AI reads a post, plaintext also reaches the environment running that AI. E2EE does not mean the AI cannot see the conversation.

Storage encryption is also useful, but it does not meet this goal if an SQL result or database dump contains plaintext messages. Encryption therefore happens before the application sends data to the database.

Do not store plaintext decryption keys in the database

Storing a room’s message key in plaintext alongside its ciphertext would expose both in the same database leak.

Sakura gives each device keys for key delivery. The server stores device public keys and room keys encrypted for those devices. Device private keys used for decryption are not sent to the server.

Key delivery combines shared secrets from ML-KEM-768 and P-256 ECDH, derives a wrapping key with HKDF-SHA-256, and wraps the room key using AES-256-GCM. This describes the current implementation; it is not a claim that the protocol has received an independent cryptographic audit.

Choosing the recipient required work beyond selecting algorithms. A device successfully logging in with a passkey should not automatically receive a room key without further checks.

For new browsers, I added a path that requires approval from an existing browser that can read the conversation. The notification shows the browser, operating system, and registration time and allows approval or rejection. Browser information is not proof of identity. It helps users check whether they added the device themselves.

Include OGP metadata in the encrypted content

Adding link preview cards introduced more data to store. Page titles, descriptions, and images can reveal the conversation topic and destination URL.

Titles and descriptions go inside the message JSON and are encrypted with it. Initially, I also embedded resized images in messages. However, image-heavy histories increase database storage and history response sizes, so I moved image bodies to R2.

LocationStored data
D1Encrypted messages. After decryption, they also contain OGP titles, descriptions, image reference IDs, image decryption keys, and IVs
Private R2Encrypted thumbnails, without their decryption keys
DevicesDevice private keys, available room keys, and decrypted messages

The current implementation resizes images to 320×180 and encodes them as JPEG with a Base64 representation no longer than 16,000 characters. It generates a random 256-bit key and a 96-bit IV for each image, encrypts the image using AES-256-GCM, and uploads it to R2. The maximum stored ciphertext size is 12,016 bytes.

The image key is not sent to the upload API. It goes inside the encrypted message instead. Image encryption includes the project ID and image ID as AAD. Copying both D1 and R2 does not, by itself, reveal the image decryption keys.

R2 object names contain project IDs and image IDs, so the design does not hide which images belong to the same room or their approximate sizes. Previously embedded thumbnails remain readable; they are not migrated automatically.

Fetching OGP metadata still reveals URLs to the server

Encrypting stored data does not prevent the server from seeing a URL when it fetches OGP metadata.

Browsers cannot necessarily fetch and read HTML from arbitrary sites, so the Sakura API retrieves page metadata and images and returns them to the browser. The browser then encrypts the data for storage. The destination site also receives a request.

This matters for URLs containing confidential information or authentication values. The design does not keep the URL hidden from the server throughout its lifetime, and the user documentation explains that. Readers do not refetch the destination each time they view a post.

The retrieval API requires authentication and write permission and has a per-user rate limit. It restricts destinations to public HTTPS URLs, checks redirect targets, and imposes time and size limits. A failed lookup omits the card or image without preventing the post from being sent.

Require membership even when returning ciphertext

Images live in a private R2 bucket and are accessed through a Worker binding. Posts do not embed public bucket URLs.

When an image approaches the visible area, the browser requests it from a same-origin API. The API checks the session and room membership. It checks again before returning the response because access might be revoked while waiting for R2. Image API responses use Cache-Control: no-store.

Deleting a room also starts background deletion of R2 objects under that project’s prefix. An upload followed by a canceled post can leave an unreferenced image until room deletion. Because the server cannot extract references from encrypted messages, automatic garbage collection needs a separate design.

What the tests cover, and what remains outside the design

The R2 change was implemented in PR #52. Device approval and rejection were added in PR #46. The server repository is currently private, so these implementation links are only accessible to people with repository access.

Image tests cover encryption and decryption, rejecting modified ciphertext, failure to decrypt under a different project ID, cross-room access denial, reader upload denial, and denial after leaving a room. Another test disables a member during the R2 read and verifies that the API does not return the data. Browser tests verify that durable pending-send data does not contain plaintext image bodies or image decryption keys.

In production, I also checked posting and displaying an image-bearing URL and deleting the R2 images when deleting a test room. These checks verify particular implemented conditions; they do not prove the security of the entire system.

Usernames, room names, membership relationships, post timestamps, and traffic volumes remain visible metadata. Messages and keys already obtained by a participant cannot be recalled. New participants currently receive historical keys so they can read earlier posts. Compromising a device holding those keys can therefore expose past messages as well.

Compromising the server that delivers the application and serving malicious JavaScript, or substituting public keys during key delivery, requires separate consideration from a leak of stored data. This implementation is not a guarantee that the server need never be trusted.

In v0.1.0, message contents and OGP images are encrypted on clients, and plaintext decryption keys are not stored server-side. Collecting unreferenced images and strengthening device and key-delivery verification remain separate work.