Implemented encryption architecture for the local MCP server.
- Every human-readable field is encrypted at rest with AES-256-GCM. Plaintext columns are structural only: opaque identifiers, timestamps, HMAC lookup tokens, and domain pseudonyms. No user-authored value is stored in the clear — including project ids, which are stored as an HMAC of the caller's id (original encrypted). See §1.
- Your passphrase is the only thing that unlocks it. It never leaves your machine. We can't recover it; nobody else can read your ledger.
- The cloud sync relay (optional) is content zero-knowledge but not metadata zero-knowledge. Content, channel/author/entity refs, and user-state values are all opaque to the server. But platform names (
surface), timestamps, public keys, and pseudonym counts are stored as plaintext for indexing and auth — a malicious relay operator can fingerprint each user's platform list, activity hours, and domain count without decrypting anything. Full surface in §9. - Every agent operation is logged. The audit trail is HMAC-chained — tampering breaks the chain and is detectable on replay.
- Per-tool scope enforcement. Each registered MCP tool gets read/write permissions; the gate sits in front of every ledger operation.
The deep dive below covers the algorithms (scrypt, HKDF-SHA256, blind indexing), the key-rotation protocol, the threat model, and what an attacker with each level of access can and cannot do.
Every human-readable field in the database is encrypted with AES-256-GCM before storage. The plaintext columns are structural: event_id (opaque ULID), timestamps, ledger_sequence, domain pseudonyms, and HMAC lookup tokens (ns_key_hash, blind-index token, and active_projects.project_id — an HMAC of the caller's id, with the original encrypted in project_ref_enc).
| Table | Encrypted columns | Plaintext columns |
|---|---|---|
timeline_events |
summary, intent, outcome, platform, detail, artifacts, tags, session_id, parent_event_id | event_id (ULID), timestamp, ledger_sequence, domain (pseudonym) |
core_identity |
display_name, roles, expertise_domains, communication_style | — |
global_preferences |
timezone, custom | — |
domain_context |
context | domain (pseudonym), updated_at |
active_projects |
name, domain, status, summary, project_ref_enc (the caller's original id) | project_id (opaque HMAC key), last_touched |
schemaless_facts |
namespace, key, value | fact_id (ULID), domain (pseudonym), ns_key_hash (deterministic HMAC lookup), created_at, updated_at |
audit_log |
agent_id, operation, scopes_accessed, event_ids, detail | timestamp, response_size_bytes |
domain_map |
encrypted_name | pseudonym |
project_id(v0.2.2). Theactive_projectskey isHMAC(blindKey, project_id)— opaque, and stable so upsert still matches. The caller's original id is encrypted inproject_ref_encand returned on read. No user-authored value is stored (or synced) in the clear, whatever id the caller chooses. Legacy plaintext rows are re-keyed on open.
Deterministic HMAC columns (ns_key_hash, blind-index token, domain pseudonyms) enable lookup without decryption but leak equality/frequency/co-occurrence under key compromise — same tradeoff as §3.
The domain column in timeline_events and domain_context stores HMAC-SHA256 pseudonyms (e.g., d_1ac6397ab4d2), not real domain names. The domain_map table maps pseudonyms to encrypted real names. An attacker sees opaque identifiers — they cannot determine whether a user has "health" or "finance" domains.
Passphrase mode (production):
User passphrase
→ scrypt(N=131072, r=8, p=2) + stored salt
→ 32-byte master key (IN MEMORY ONLY)
→ HKDF per domain → domain encryption key
→ HKDF global → global encryption key
→ HKDF per domain → blind index key
On disk: master.salt, master.verify (HMAC hash for passphrase validation), mode file. No key file. The derived key exists only in process memory and is zeroed (Buffer.fill(0)) on shutdown.
Dev mode (local development):
Random 32-byte key → stored in master.key (0o600)
→ same HKDF derivation as above
master_key (scrypt-derived or random)
├── global_key = HKDF(master, "usrcp-global", "usrcp-encryption-v1")
├── domain_key[d] = HKDF(master, "usrcp-domain-{d}", "usrcp-encryption-v1")
└── blind_key[d] = HKDF(master, "usrcp-blind-{d}", "usrcp-blind-index-v1")
Domain keys provide cryptographic isolation: a coding agent with access to domain_key["coding"] cannot derive domain_key["health"] without the master key.
Ledger.rotateKey() atomically re-encrypts all data:
- Generate new master key (from new passphrase or random)
- In a single SQLite transaction: decrypt every field with old key, re-encrypt with new key
- Rebuild blind index with new key material
- Update key version file
- Zero old key in memory
Exposed via usrcp_rotate_key MCP tool.
Rotation re-encrypts every ciphertext column under the new master key. Most rows round-trip cleanly, but a row whose ciphertext fails GCM authentication during rotation is unrecoverable — the plaintext cannot be produced, so it cannot be re-encrypted. Causes include deliberate tampering, on-disk corruption, or a row written by a key that no longer matches (e.g. a partial restore).
When this happens:
- The row is left in place under its old ciphertext and old domain pseudonym. It is not silently dropped, so external audits of the raw database can still observe that a tampered row exists.
- Rotation does not abort. The remaining rows are re-encrypted as normal.
- Each skipped row is counted in the
skippedfield of the rotation result, alongsidereencrypted. - A
key_rotation_skippedaudit-log entry records the total skipped count. - Under the new master key, skipped rows remain unreadable: reads surface them with tampered-field markers rather than crashing the whole timeline. The blind index has no tokens for them, so keyword search will not match.
Legacy plaintext rows (predating domain-scoped encryption) are not considered damaged — they are treated as plaintext, encrypted under the new domain key, and become first-class ciphertext rows from that point on.
USRCP's search is exact keyword matching over an HMAC blind index. There are no embeddings, no vector similarity, and no semantic recall. This is a deliberate architectural choice, not an omission. See strategy/SEARCH_DECISION.md for the full tradeoff analysis. Briefly:
- Embeddings leak semantic structure even when encrypted. Two records with similar plaintext produce similar embeddings; a compromised master key turns the encrypted index into a semantic similarity oracle over every record, not a per-record decryption attack. Blind indexes do not have this property — a compromised blind-index key reveals keyword membership but does not reveal semantic clustering.
- Exact keyword matching is sufficient for structured state. Queries
like "find events tagged
authin thecodingdomain" are the intended shape of USRCP search. Fuzzy recall over conversational history is the job of a separate semantic memory layer; callers are free to run one in parallel.
Search over encrypted data uses HMAC-SHA256 blind index tokens:
- On write: text is split into words. Each word generates:
- A full-word HMAC token
- Character n-gram tokens (3-6 chars) for prefix matching
- 3 random noise tokens (adds modest uncertainty; does not defeat frequency analysis — see below)
- On search: query words are HMAC'd with the same key
- Token matching finds events without exposing plaintext
Example: "authentication" generates tokens for aut, auth, uthen, thent, henti, authentication, etc. Searching "auth" matches because both the stored n-gram and the query produce the same HMAC.
Each event inserts 3 random 16-hex-character tokens alongside the real tokens. Noise tokens are the same length as real HMAC tokens, so they are length-indistinguishable from real tokens.
Be clear about the limits: 3 random tokens next to many deterministic n-gram tokens do not meaningfully flatten the token distribution, and they do not defeat frequency analysis. Identical plaintext always produces identical real-token sets, so an attacker holding the blind index key (or simply observing the index over time) can still see exact-keyword membership, equality patterns, and co-occurrence patterns across events. The noise adds modest uncertainty to per-token frequency counts — nothing more. This is consistent with the blind-index tradeoff described in §3: a compromised blind-index key reveals keyword membership.
- Semantic similarity. "anxiety medication" and "sertraline dosage" do not match each other unless they share a token. This is by design — see the architecture decision above.
- Ranking. Matching is boolean per token; there is no TF-IDF score. If ranking is needed, callers should fetch all matches and sort application-side (e.g., by timestamp).
- Typo tolerance. "authenication" will not match "authentication" because their n-grams differ. Fuzzy matching would require either normalized tokens on write (reducing the search space) or a separate fuzzy-match layer that USRCP deliberately does not supply.
Every operation is logged to audit_log with encrypted fields:
| Field | Content |
|---|---|
timestamp |
Plaintext (when) |
agent_id |
Encrypted (who called) |
operation |
Encrypted (what operation) |
scopes_accessed |
Encrypted (which domains) |
event_ids |
Encrypted (which events) |
response_size_bytes |
Plaintext (how much data) |
The audit log is readable via the usrcp_audit_log MCP tool and Ledger.getAuditLog(), which decrypt fields with the global key.
secure_deletepragma: SQLite zero-fills pages when rows are deletedLedger.secureWipe(): WAL checkpoint + VACUUM after deleteLedger.close(): zeros master key buffer in memory- Event pruning: writes encrypted empty values (not plaintext
'{}')
Defense-in-depth at two layers:
Zod schemas (MCP transport layer): max string lengths, max array sizes, bounded records.
Ledger validation (application layer): domain (100 chars), summary (500), intent (300), platform (100), tags (50 items), artifacts (50 items, 2048 ref), detail (64KB), idempotency_key (100), session_id (100).
All intermediate Buffers in encrypt/decrypt paths are zeroed after use via Buffer.fill(0). This includes cipher update/final buffers, packed ciphertext, HMAC digests, and the master key on shutdown. This reduces the window for heap extraction but does not eliminate it — see Known Limitations.
Key derivation uses hardened scrypt parameters: N=131072 (2^17), r=8, p=2. At these settings, each derivation takes ~200-500ms on modern hardware. Brute-forcing a 12+ character passphrase requires years on consumer hardware (including M2 Max).
USRCP_PASSPHRASEenv var is preferred over--passphraseCLI flag- CLI flag warns that the passphrase is visible in
/proc/<pid>/cmdline - Env var is deleted from
process.envimmediately after reading - Passphrase is never logged or echoed
usrcp keychain store (or usrcp init --keychain) places the passphrase in the
macOS Keychain or the Linux Secret Service, and serve/status/sync fall back
to it when no env var or flag is provided. Honest framing of what this changes:
- Better than the env-block pattern: nothing in plaintext in editor JSON/TOML configs or shell rc files; the entry is encrypted at rest by the OS and gated on the login session.
- Not a boundary change: any process running as the logged-in user can read
the entry through the same CLI (
security/secret-tool), exactly as it could read a config file or the server's heap (see §8). The keychain removes the at-rest plaintext copy; it does not defend against a compromised user session. - Mechanics: the secret is stored base64-encoded (
usrcp-b64:prefix) because both backend CLIs are lossy for raw non-ASCII values; macOS commands are fed viasecurity -istdin so the passphrase never appears in a process list; every store is round-trip verified; all backend calls carry timeouts so a locked keychain degrades to the env-var error path instead of hanging a headless MCP-spawned server.
- Disk theft / backup exposure: All data encrypted at rest. In passphrase mode, no key file exists on disk.
- Unauthorized agent access: Domain-scoped keys prevent cross-domain data access.
- Forensic recovery of deleted data:
secure_deletepragma + VACUUM zero-fill deleted pages.
-
Frequency analysis of the search index under blind-index-key compromise: Real tokens are deterministic HMACs — identical plaintext yields identical token sets. The 3 random noise tokens per event add modest uncertainty but do not flatten the distribution; an attacker holding a blind-index key can test exact-keyword membership and observe equality and co-occurrence patterns across events (see §3).
-
Heap extraction (
gcore <PID>+strings): A local attacker with root can dump process memory and extract the master key, derived keys, and any decrypted plaintext currently in the V8 heap. All Buffers we control are zeroed after use, but JavaScript strings are immutable and GC-managed — once plaintext becomes a string, we cannot zero it. Mitigation for Pro/Enterprise: Rust or Go sidecar for decryption in a memory-safe runtime, or TEE (Trusted Execution Environment). -
Debugger attachment (
ptrace,lldb): A local attacker can attach a debugger to the running process and read any value in memory. No Node.js mitigation exists. Mitigation: Run withptracedisabled viaprctl(PR_SET_DUMPABLE, 0)on Linux, or use a sandboxed runtime. -
No FIPS 140-2: Node.js
cryptouses OpenSSL but is not FIPS certified. Regulated industries requiring FIPS compliance need a certified crypto module. -
No HSM: Keys are software-managed. Hardware Security Module integration would require native bindings to PKCS#11 or platform-specific APIs.
-
stdio transport (default): MCP communication is unencrypted plaintext over stdio. Any process that can read the pipe — or any local attacker that can attach to the spawning client or the server — sees decrypted data in transit. This is the default because MCP clients (Claude Desktop, Claude Code) auto-spawn the server over stdio and the UX is frictionless. Mitigation: run
usrcp init --transport=httpandusrcp serve --transport=httpfor a TLS + bearer-authenticated HTTPS transport (see §10). -
Timestamps remain plaintext: Activity timing patterns are visible. An attacker knows when the user was active but not what they did.
-
V8 string immutability: JavaScript strings cannot be zeroed. Decrypted field values (summaries, intents, etc.) persist as V8 strings until garbage collected. The GC does not zero freed heap pages.
For Pro/Enterprise tiers where the threat model includes local attackers with root:
- Rust decryption sidecar: Move all encrypt/decrypt operations to a Rust process that communicates with the Node.js server via a Unix socket. Rust provides guaranteed memory zeroing via
zeroizecrate. - TEE integration: Run the decryption sidecar inside an Intel SGX or ARM TrustZone enclave. The master key never exists in normal process memory.
- Authenticated MCP transport: Available today — see §10.
- FIPS mode: Use a FIPS-validated OpenSSL build or BoringSSL with the Rust sidecar.
usrcp-cloud (the optional hosted sync relay) is content zero-knowledge but metadata-leaky. Every _enc column in the relay's Postgres schema is opaque ciphertext under a key derived from the user's master passphrase — the relay never sees decrypted content, channel references, author references, entity refs, the encrypted user-state columns, or pairing-bundle plaintext. But several columns are stored as plaintext for indexing, cursor, and authentication purposes, and an attacker with read access to the relay database (malicious operator, leaked backup, subpoena response) can use them to fingerprint users without decrypting a single byte of content.
- Content of any kind:
channel_ref_enc,author_ref_enc,content_enc,entity_refs_enconstream_events;summary_enc,intent_enc,outcome_enc,detail_enc,artifacts_enc,tags_enc,session_id_enc,parent_event_id_encontimeline_events; the encrypted user-state columns oncore_identity/global_preferences/domain_context/active_projects(includingproject_ref_enc, the caller's original project id) /schemaless_facts;pairing_bundles.encrypted_bundle. - Domain names: the relay stores HMAC-SHA256 pseudonyms (e.g.
d_1ac6397ab4d2), never the realcoding/personal/healthstrings. - The pairing OOB secret: the 16-byte secret that travels device-to-device (paste / AirDrop / QR) is never POSTed; the bundle decryption key is
HKDF-SHA256(IKM=secret, salt=code)derived client-side on both ends.
| Field | Table | What it leaks |
|---|---|---|
user_public_key (Ed25519 PEM) |
every per-user table | Linkable identifier across all surfaces — one key joins timeline + stream + facts + projects + pairing + revocations |
surface |
stream_events |
Exact platform list per user (slack, discord, telegram, imessage, claude-code, etc.). Strongest fingerprint. |
ts_ms, client_timestamp, server_timestamp |
stream_events, timeline_events |
Activity hour distribution → time-zone, work/sleep patterns |
side, content_kind, embedding_present |
stream_events |
In/out ratio, content-type ratio, embedding-adoption signal |
domain_pseudonym |
timeline_events, domain_context, domain_maps, schemaless_facts |
Pseudonyms are stable per user — relay can count events per pseudonym and rank domains by volume, even without decoding names |
ledger_sequence, server_seq |
timeline_events, stream_events |
Monotonic event count per user |
version, updated_at, last_seen_at |
LWW state tables | Write count + recency per user-state surface |
revoked_keys.public_key ↔ rotated_to |
revoked_keys |
Full identity-rotation graph (every old key the user has held, linked to current) |
pairing_bundles.code, owner_public_key, created_at |
pairing_bundles |
When the user paired devices; owner identity (DB-dump only, not internet attacker — see schema comment on the v2 pairing flow) |
seen_nonces.user_public_key, seen_at |
seen_nonces |
Request rate per user |
project_id |
active_projects |
Opaque HMAC per project (v0.2.2) — stable per user, so the relay can count projects, like domain_pseudonym. Not caller content. |
dims |
stream_embeddings |
Embedding vector dimension (e.g. 768) — fingerprints the embedding-model family, even though the vector itself (vec_enc) is encrypted |
created_at_ms |
stream_embeddings |
Additional per-embedding timing channel |
ingested_at |
stream_events |
Server-side ingest timing (relay-clock activity channel beyond client timestamps) |
For each user the operator can reconstruct, without decrypting any content:
- The exact set of capture platforms in use (the
surfaceenum is short and known). - Activity hour distribution per platform → workday / weekend / time-zone fingerprint.
- Number of distinct ledger domains and their volume ranking (via stable per-user pseudonyms).
- Cross-surface correlation via the shared
user_public_key. - Identity-rotation history via
revoked_keys. - Pairing cadence via
pairing_bundles.created_at.
For regulated industries (health, finance, legal) where USRCP's pitch includes "the provider never sees plaintext," this metadata surface is a real boundary to communicate honestly. "Zero-knowledge for content" is true; "zero-knowledge for everything" is not.
- Don't use the relay. USRCP works fully without
usrcp-cloud; the relay is opt-in for multi-device sync. A single-device install leaks none of the above. The README's install flow does not enable the relay by default. - Self-host the relay. The schema and protocol are the same, but the operator is you. Eliminates third-party trust on this surface entirely.
- Use
usrcp export/usrcp importfor occasional multi-device synchronization without the relay (manual but zero metadata flow).
These would reduce the metadata surface but each costs something. Listed for transparency, not committed:
- Encrypt
surfaceunder a per-user metadata key: relay seesenc:…; can't enumerate platforms. Cost: loses ability to index/cursor by surface server-side. - Snap
ts_msto nearest hour or day before upload: hides exact timing. Cost: loses ms-precision ordering for stream stitching. - Re-randomize
domain_pseudonymper push (different pseudonym for same domain each event): breaks per-pseudonym aggregation. Cost: client must maintain pseudonym→domain mapping (already does, but flush per push). - Per-session blinded auth tokens instead of long-lived public key: eliminates cross-table linkability. Cost: major protocol refactor; rotation semantics get harder.
- Periodic-purge of
revoked_keyswith TTL: shrinks the rotation-history window. Cost: can't reject ancient revoked keys after the window.
The right answer depends on how strongly the project markets "zero-knowledge" going forward and which audiences it targets. Today the framing in the README ("zero-knowledge for content, not for traffic shape") matches the actual posture; this section quantifies what "traffic shape" means.
usrcp serve --transport=http runs the MCP server over HTTPS on
127.0.0.1, gated by a 32-byte bearer token. This closes the plaintext-
over-stdio gap described in §8, at the cost of requiring the server to
be running as a standalone process (stdio's auto-spawn convenience goes
away).
- Self-signed TLS certificate: Generated once at
~/.usrcp/users/<slug>/tls/{cert,key}.pem, mode0600, RSA-2048 with SHA-256 signature, SAN coveringlocalhostand127.0.0.1. Valid 1 year; regenerate by deleting and restartingserve. Not a public-CA cert — clients must pin this cert or otherwise trust it explicitly. - Bearer token: 32 bytes of
crypto.randomBytes, stored hex-encoded at~/.usrcp/users/<slug>/auth.token, mode0600. Compared withcrypto.timingSafeEqualon every request. - Scope: Listens on
127.0.0.1only — not on external interfaces. The cert's SAN reflects that; the server rejects any non-TLS request.
- Does not trust any CA. The cert is self-signed and uniquely tied
to this install. Clients that don't pin it must use
rejectUnauthorized: falsescoped to this endpoint; that weakens the TLS story but is acceptable for127.0.0.1because the network path is not attacker-reachable. - Does not prevent local heap extraction. The same limitations from §8 apply — a local attacker with root who can attach to either end of the connection still sees plaintext in process memory.
- Does not rotate tokens. Delete
auth.token, restart the server, and the nextensureAuthToken()call generates a new one. Update dependents manually.
usrcp init --transport=http writes an HTTP-style entry to Claude
Desktop's config:
"usrcp": {
"type": "http",
"url": "https://127.0.0.1:9876/mcp",
"headers": { "Authorization": "Bearer <token>" }
}The user is responsible for running the server (the client won't spawn
it). Typical options: usrcp serve --transport=http in a dedicated
shell, or a launchd/systemd service. Registered entries auto-spawn
only under the stdio-style { command, args } form.