End-to-end encrypted content is deliberately awkward to search. A conventional search service receives a query, reads an index, and returns matching documents. Inpages.me takes a different route for protected journal fields: it sends an account-scoped catalog to the browser, unlocks protected values only when the private vault is open, and compares the query locally.
Define the search boundary before choosing an algorithm
“Search encrypted content” can describe several very different systems. It can mean a server-side encrypted index, a trusted execution environment, or a browser that decrypts its own data before comparing it. Global Search is the third design. Rails does not compare a phrase with protected writing, and there is no server-held searchable-encryption index.
That distinction matters. The current product can search a protected Memory title and a protected diary note after local unlock, but it does not claim full-text search across every protected field or encrypted file. The browser encryption architecture describes why the usable Master Key stays in the browser.
Build an account-scoped catalog, not a global corpus
The Global Search endpoint calls GlobalSearch::Catalog with Current.identity. Each collection is selected through that identity: Journals belong directly to it; Memories are joined through its Journals; diary entries and North Stars are read from its own associations. Destination paths are created alongside the records.
catalog(identity) = {
journals: identity.journals,
memories: Memory.joins(:journal).where(journals: { identity_id: identity.id }),
diary_entries: identity.daily_checkins,
north_stars: identity.north_stars
}
The endpoint returns only what the browser needs to display, match, and open a result. For protected fields, that value is still encrypted when Rails reads it from storage. Account scoping is therefore an authorization requirement independent of encryption; ciphertext from another account is still not an acceptable search result.
Decrypt only in the browser that holds the key
The catalog marks Memory titles and diary notes with an encrypted flag. The Stimulus controller receives the stored value, then asks the browser vault whether it is locked. When it is open, the controller calls the browser encryption service with the locally available Master Key and prepares a readable value for matching.
text = storedValue
if encrypted && vaultIsOpen()
text = decrypt(storedValue, masterKey)
end
searchValue = text.toLowerCase()
This is the same trust boundary used when a protected Memory is displayed. Web Crypto performs AES-GCM decryption in the browser; the Web Cryptography specification defines the authenticated-encryption operation used by the underlying browser API.
Match without sending the query to Rails
Global Search fetches its catalog on the first non-empty query and retains it for the current page session. The typed phrase is lowercased in the controller and compared with lowercased local fields through a case-insensitive substring match. The request carries no query parameter or search body for Rails to execute.
query = input.value.toLowerCase().trim()
results = memories.filter(memory =>
memory.searchTitle.includes(query)
)
This keeps the phrase out of the application-level search request and prevents a Rails query over protected text. It does not make the browser a blind client: it receives the authorized catalog and, after unlock, holds the plaintext needed to show matching results.
Fail closed when the vault is locked or decryption fails
If the vault is locked, the controller leaves an encrypted value unreadable for matching. If local decryption raises an error, it replaces that search value with an empty string. In either state, a protected title or diary note produces no result rather than falling back to a server-side match or displaying unreadable ciphertext as a title.
North Star direction and notes are different: they are currently searched as stored text, so they are available without opening the private vault. A search design must name such differences plainly instead of treating every record type as equally protected.
Keep the limits explicit
This design trades server-side indexing for a browser-side catalog. It is appropriate for the fields and collection sizes Global Search serves today, but it is not a general encrypted-search protocol or a promise that every private byte is searchable. Full-text bodies, attachments, ranking, and large-account performance would need separate designs and threat-model review.
Browser-side decryption also inherits browser risk. An unlocked Master Key is available to code running in the trusted application origin; a malicious extension, cross-site scripting flaw, or compromised device remains outside the protection this search flow provides. The end-to-end encryption threat model covers those limits, while testing browser-based encryption in Rails explains how regressions at this boundary are exercised.