Security model
IA Cloud Memory treats its own backend as untrusted storage. Every byte of user data — file contents, paths, manifests, commit messages — is encrypted in the browser before it ever reaches the server.
Two distinct secrets
- Account password. Authenticates with the server. Hashed with argon2id (m=19456, t=2, p=1) plus a server-side pepper — a secret mixed in before hashing that, unlike the salt, is never stored in the database — before storage. The server checks it on login but never derives encryption keys from it.
- Encryption passphrase. Stays in your browser. We derive a 256-bit master key from it via PBKDF2-SHA256 with 600 000 iterations, salted by a random per-user 16-byte salt that's stored on the server (the salt is not a secret).
We can recover or reset your account password (after the usual proof of email control). We cannot recover your encryption passphrase — losing it means losing your data.
How files are encrypted
Every file is stored as two separate ciphertexts:
- A content envelope: AES-256-GCM ciphertext of the file bytes, encrypted with a random per-object 256-bit Content Encryption Key (CEK). Hashed with SHA-256; this hash is the file's server-side address.
- A wrap: a 66-byte AES-256-GCM ciphertext of the CEK, encrypted with the master key. Stored separately, keyed by the content hash.
Manifests (the path → content-hash mapping) and commits (parent links, messages) are themselves files: encrypted JSON, stored as ordinary objects.
Why two-layer wrapping?
It makes key rotation cheap. To switch master keys we only need to re-wrap the per-object CEKs (66 bytes each), not re-encrypt the content envelopes. Hashes don't change, manifests don't change, commits don't change. The full chain stays valid.
What the server stores
Object ::= magic||v||flags||nonce||AES-GCM(content, CEK)
Wrap ::= magic||v||flags||nonce||AES-GCM(CEK, masterKey)
Manifest ::= encrypted JSON { path → contentHash }
Commit ::= encrypted JSON { parent, manifestHash, message }
Ref ::= "main" → commitHash (CAS-protected via etag)
User ::= { id, email, password_hash (argon2id+pepper), crypto_salt (public) }
Session ::= { id (sha256(token)), user_id, expires_at }
APIKey ::= { id, user_id, token_hash (sha256), name }Per-user isolation is enforced by SQL. Every encrypted-data table has a user_id column, and all queries filter on it. There is no API path that can return another user's ciphertexts.
What the server CANNOT do
- Read the contents of any file.
- List the names of your files.
- Read commit messages.
- Re-derive your master key — it never sees your encryption passphrase.
What the server CAN see (metadata)
- Your email and account creation time.
- The number, size, and timestamps of objects you store.
- The DAG of commits (parent links) — connected by hash, no payload.
- API key names and last-used timestamps.
Full-text search is client-side only
memory_search (the MCP tool) and its underlying @claude-memory/search package run entirely inside the local MCP server process. Chunking, the keyword index, and the cache that backs it all live in packages/search, packages/client, and packages/mcp-server — never in packages/backend. The backend has no search endpoint, no index, and no code path that could construct one; it continues to see only the same encrypted objects/wraps/refs/commits described above.
The search index itself is cached on disk, encrypted at rest with AES-256-GCM under a dedicated sub-key. That sub-key is derived from your master key via HKDF-SHA256 with a fixed, purpose-specific "info" string (domain separation) — so it is cryptographically distinct from the per-object Content Encryption Keys used for your actual memory files. Losing or corrupting this cache is a non-event: it is rebuilt automatically from the encrypted corpus on next use, the same way the rest of the client does after a fresh Repo.open().
This is keyword (full-text) search v1. Semantic/embedding-based search is intentionally out of scope for now and, when it ships, will follow the same rule: it runs client-side, or it doesn't ship.
The link graph (backlinks) is client-side only, too
Memory files can reference each other with [[wikilinks]]. memory_backlinks and memory_graph (the MCP tools) and the dashboard's "Referenced by" panel are all built on packages/search/src/links.ts and packages/search/src/graph-core.ts — link parsing and resolution over plaintext that only ever exists client-side, reusing the exact same corpus-decrypt pass as memory_search (readCorpus) rather than a second one. This is a design guardrail, not just an implementation detail: no link, backlink, or graph data is ever sent to the backend, and none should ever be added. The MCP server's cache (GraphService) mirrors the search index's: an encrypted on-disk cache under ~/.ia-cloud-memory/graph-index/, its own HKDF sub-key (domain-separated from both the search cache's and your content keys), disposable and rebuilt from the encrypted corpus on any read failure. The browser dashboard, by contrast, uses the package's @claude-memory/search/browser entry point directly — stateless, no cache, no Node built-ins in its bundle — since the already-open Repo in your tab can answer a backlinks query on demand.
Identity is fully local, by design
Signup, login, sessions, password reset and account deletion are all handled by IA Cloud Memory itself — there is no external identity provider in the loop. This is a deliberate departure from the ILYGO-wide convention of delegating human identity to the shared Keycloak SSO (auth.ilygo.ch): that SSO is a single point of failure for every ILYGO app that depends on it (it went down for 9 hours on 2026-07-15), and this product's job — a working memory for an AI agent — should not inherit that risk. See packages/backend/src/auth.ts for the full rationale.
Threat model — out of scope (v1)
- Compromised browser / malicious extensions: game over for any zero-knowledge web app.
- Server-side rate limiting and abuse protection: minimal in v1.
- Hardware-backed key storage / passkey unlocking: planned.
- Forward secrecy on rotation: the old master key remains usable for previously-rotated wraps unless explicitly purged.