A private journal creates an awkward systems problem: the application must save and return a page without needing to understand the protected writing inside it. Inpages.me handles that boundary in the browser. Rails stores the result, but it does not perform the encryption or receive the usable key.
Architecture at a glance
The design separates a password-derived key from the key that encrypts journal data. This lets a person change how they unlock the vault without making every Memory depend directly on a password.
- KEK
- A Key Encryption Key derived from the vault password. It protects another key; it does not encrypt every Memory directly.
- MK
- A random 256-bit Master Key used to encrypt protected Memory fields and files.
- EMK
- The Master Key after it has been encrypted with the KEK. This encrypted form can be stored by the server.
Creating the browser vault
Vault setup starts by generating a random 16-byte salt in the browser. Inpages.me runs Argon2id with 64 MiB of memory, three iterations, and parallelism of four to derive a 32-byte KEK from the entered password. Argon2id is a memory-hard password function described in RFC 9106.
The browser then creates a separate 32-byte Master Key with crypto.getRandomValues. It wraps that key with AES-GCM using the KEK and a fresh 96-bit initialization vector. The resulting Encrypted Master Key is serialized as the IV followed by the ciphertext and authentication tag.
salt = randomBytes(16)
kek = argon2id(password, salt, memory: 64 MiB)
mk = randomBytes(32)
emk = AES_GCM.encrypt(key: kek, plaintext: mk, fresh_iv: true)
Rails receives the salt, Argon2id parameters, EMK, and an encrypted account-bound verifier. It does not receive the password, KEK, or plaintext Master Key. On a later device, the browser can fetch the EMK and derivation parameters, derive the KEK again, and attempt to unlock the same Master Key locally.
AES-GCM is an authenticated-encryption mode: decryption fails when the key, IV, ciphertext, or authentication tag does not match. Inpages.me uses the browser's Web Cryptography API implementation of AES-GCM rather than sending plaintext to a server-side encryption service.
Protecting text and files before upload
A new Memory uses the protection choice in Settings. New accounts begin with Encrypted writing & files, and an owner can choose Standard protection for future Memories. When an existing Standard Memory changes to Encrypted writing & files, the editor flushes pending changes, requires an unlocked vault, and transforms the protected fields in the browser.
The current protected set includes:
- the Memory title and rich-text content;
- external-link text and URLs;
- keepsake file bytes; and
- inline-image bytes and their references inside the rich text.
Text becomes Base64-encoded AES-GCM output. Files remain binary: the browser reads each file into an ArrayBuffer, encrypts it with the same Master Key and a new IV, then uploads an application/octet-stream blob. Every encryption operation receives a fresh random IV.
Hashtags are not part of that encrypted set. They remain plaintext metadata in both protection states. This is a deliberate boundary, not an accidental claim that every field associated with a Memory is encrypted. The user-facing guide on journal encryption metadata explains the practical consequence without the implementation detail.
What Rails stores
Rails receives values in the same domain fields used by a Standard Memory, but the encrypted state tells the application to treat them as ciphertext. It validates ownership and payload shape, stores the transformed fields, and never calls a server-side decrypt function.
| Concern | Browser | Rails server |
|---|---|---|
| Vault password | Receives it for local derivation | Does not receive it as an encryption input |
| Master Key | Generates and uses it | Stores only its encrypted form |
| Memory text | Encrypts before submission | Stores ciphertext |
| Memory files | Encrypts raw bytes | Stores opaque blobs |
| Metadata | Supplies required record context | Retains operational metadata |
Active Storage normally analyzes uploaded media to obtain properties or generate variants. Inpages.me skips analysis for encrypted application/octet-stream blobs because the server cannot interpret their bytes. That means the protected path cannot depend on server-generated thumbnails or plaintext media inspection.
Reading the Memory again
When Rails renders a protected Memory, it places the ciphertext into data attributes and returns links to opaque file blobs. A Stimulus controller waits for the browser vault to be unlocked, decrypts the fields, and inserts plaintext into the page. Encrypted media is fetched, decrypted into an in-memory Blob URL, displayed, and released when the view disconnects.
If the vault is locked, the editor does not silently fall back to Standard protection. Protected fields stay unavailable until the owner unlocks with the password or imports the Recovery Key. The Recovery Key guide covers the user-facing recovery path.
The trade-offs we keep visible
Browser-side encryption moves an important trust boundary away from the database, but it does not remove trust from the browser. For convenience, an unlocked Master Key is Base64-encoded in per-account localStorage until logout or an explicit lock. That makes refreshes practical, while leaving the key exposed to script running in the same origin, a malicious extension, or a compromised device.
Protected content is also different from intentionally public content. A Memory must use Standard protection before it can be published because a search engine or public reader cannot render server-unreadable ciphertext. Public and protected are separate product states with different promises.
This architecture is therefore a boundary, not a security adjective. It reduces what a database dump, storage snapshot, or routine server access can reveal about protected writing. It does not make the browser invulnerable. The next note examines that end-to-end encryption threat model directly.