Skip to content

Security: frank-bot07/usrcp

Security

docs/SECURITY.md

USRCP Security & Privacy Model

Implemented encryption architecture for the local MCP server.


TL;DR for the impatient

  • 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.


1. Encryption at Rest

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).

What's encrypted

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). The active_projects key is HMAC(blindKey, project_id) — opaque, and stable so upsert still matches. The caller's original id is encrypted in project_ref_enc and 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.

Domain pseudonyms

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.


2. Key Architecture

Two modes

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

Key hierarchy

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.

Key rotation

Ledger.rotateKey() atomically re-encrypts all data:

  1. Generate new master key (from new passphrase or random)
  2. In a single SQLite transaction: decrypt every field with old key, re-encrypt with new key
  3. Rebuild blind index with new key material
  4. Update key version file
  5. Zero old key in memory

Exposed via usrcp_rotate_key MCP tool.

Rotation and tampered rows

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 skipped field of the rotation result, alongside reencrypted.
  • A key_rotation_skipped audit-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.


3. Searchable Encryption

Architecture decision: exact-keyword, not semantic

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 auth in the coding domain" 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.

Blind index with n-gram tokens

Search over encrypted data uses HMAC-SHA256 blind index tokens:

  1. 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)
  2. On search: query words are HMAC'd with the same key
  3. 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.

Noise tokens — what they do and don't buy you

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.

What blind index does NOT provide

  • 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.

4. Audit Log

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.


5. Secure Deletion

  • secure_delete pragma: SQLite zero-fills pages when rows are deleted
  • Ledger.secureWipe(): WAL checkpoint + VACUUM after delete
  • Ledger.close(): zeros master key buffer in memory
  • Event pruning: writes encrypted empty values (not plaintext '{}')

6. Input Validation

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).


7. Runtime Hardening

Buffer zeroing

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.

scrypt cost parameters

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).

Passphrase side-channel mitigation

  • USRCP_PASSPHRASE env var is preferred over --passphrase CLI flag
  • CLI flag warns that the passphrase is visible in /proc/<pid>/cmdline
  • Env var is deleted from process.env immediately after reading
  • Passphrase is never logged or echoed

OS keychain storage (opt-in)

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 via security -i stdin 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.

8. Known Limitations & Threat Model Boundaries

What this system protects against

  • 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_delete pragma + VACUUM zero-fill deleted pages.

What this system does NOT protect against

  • 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 with ptrace disabled via prctl(PR_SET_DUMPABLE, 0) on Linux, or use a sandboxed runtime.

  • No FIPS 140-2: Node.js crypto uses 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=http and usrcp serve --transport=http for 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.

Enterprise mitigation roadmap

For Pro/Enterprise tiers where the threat model includes local attackers with root:

  1. 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 zeroize crate.
  2. TEE integration: Run the decryption sidecar inside an Intel SGX or ARM TrustZone enclave. The master key never exists in normal process memory.
  3. Authenticated MCP transport: Available today — see §10.
  4. FIPS mode: Use a FIPS-validated OpenSSL build or BoringSSL with the Rust sidecar.

9. Cloud Sync Relay — What the Operator Sees

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.

What the relay does NOT see

  • Content of any kind: channel_ref_enc, author_ref_enc, content_enc, entity_refs_enc on stream_events; summary_enc, intent_enc, outcome_enc, detail_enc, artifacts_enc, tags_enc, session_id_enc, parent_event_id_enc on timeline_events; the encrypted user-state columns on core_identity / global_preferences / domain_context / active_projects (including project_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 real coding / personal / health strings.
  • 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.

What the relay sees (plaintext)

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_keyrotated_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)

What a single DB dump enables

For each user the operator can reconstruct, without decrypting any content:

  • The exact set of capture platforms in use (the surface enum 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.

Mitigations available today

  • 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 import for occasional multi-device synchronization without the relay (manual but zero metadata flow).

Possible protocol-level mitigations (deferred — design tradeoffs)

These would reduce the metadata surface but each costs something. Listed for transparency, not committed:

  1. Encrypt surface under a per-user metadata key: relay sees enc:…; can't enumerate platforms. Cost: loses ability to index/cursor by surface server-side.
  2. Snap ts_ms to nearest hour or day before upload: hides exact timing. Cost: loses ms-precision ordering for stream stitching.
  3. Re-randomize domain_pseudonym per 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).
  4. Per-session blinded auth tokens instead of long-lived public key: eliminates cross-table linkability. Cost: major protocol refactor; rotation semantics get harder.
  5. Periodic-purge of revoked_keys with 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.


10. Authenticated HTTPS Transport (opt-in)

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).

What it does

  • Self-signed TLS certificate: Generated once at ~/.usrcp/users/<slug>/tls/{cert,key}.pem, mode 0600, RSA-2048 with SHA-256 signature, SAN covering localhost and 127.0.0.1. Valid 1 year; regenerate by deleting and restarting serve. 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, mode 0600. Compared with crypto.timingSafeEqual on every request.
  • Scope: Listens on 127.0.0.1 only — not on external interfaces. The cert's SAN reflects that; the server rejects any non-TLS request.

What it does not do

  • 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: false scoped to this endpoint; that weakens the TLS story but is acceptable for 127.0.0.1 because 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 next ensureAuthToken() call generates a new one. Update dependents manually.

Registering the transport with an MCP client

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.

There aren't any published security advisories