Security Architecture
Security Architecture Client-side encryption with in-memory-only server processing. The server never persists plaintext passwords, emails, or user content — ...
Security Architecture
Client-side encryption with in-memory-only server processing. The server never persists plaintext passwords, emails, or user content — everything on disk, in caches, and in backups is ciphertext. When the server needs to read content (to run an AI response, render an invoice, or deliver a reminder), decryption happens transiently in process memory and is discarded. This is not end-to-end encryption. See encryption-architecture.md for the full posture.
Why This Exists
- User data at rest must remain unreadable even if the database, caches, or backups are fully compromised
- Third-party data requests targeting at-rest storage cannot yield plaintext — ciphertext on disk requires the user’s key, which is derived from their credential and wrapped inside HashiCorp Vault
- AI features (image gen, long-running tasks) need server-side processing → the server decrypts in memory on demand but never writes plaintext to disk, logs, or traces
- Multiple login methods (password, passkey, recovery key) each need their own path to the master key
Two Encryption Tiers
graph TB
subgraph "Client-Managed · Client-Side Encrypted"
U[User Device]
MK[Master Key<br/>derived from credential]
EB[Encrypted Blob]
SRV1[Server / Directus]
U -->|generates| MK
MK -->|AES-256-GCM| EB
EB -->|store| SRV1
SRV1 -.->|stores ciphertext only<br/>decrypts only in RAM<br/>when needed| EB
end
subgraph "Server-Managed · Vault-Hybrid"
SK[Skill / Long-Running Task]
AES[Per-File AES Key]
VLT[HashiCorp Vault<br/>Transit wrap/unwrap]
S3[S3 Encrypted Storage]
SK -->|generates| AES
AES -->|encrypts file| S3
AES -->|wrap| VLT
VLT -->|unwrap on demand| SK
end
Client-Managed (Client-Side Encrypted)
- Used for: chat messages, app data, profile settings, user email addresses
- Master key is derived from the user’s credential on their device; the raw key is never sent to the server
- Server stores only ciphertext on disk, in Redis caches, and in backups. It can decrypt transiently in memory when the user invokes an AI response, but the plaintext is never persisted.
- Implementation: cryptoService.ts, cryptoKeyStorage.ts
- Limitation: background processing without user interaction would require a different key-release path (not currently implemented)
Server-Managed (Vault-Hybrid)
- Used for: server-generated files (images, PDFs, videos), long-running task outputs
- AES key wrapped by HashiCorp Vault using user-specific key ID
- Server can temporarily unwrap to process (e.g., AI modifying a generated image)
- Implementation: encryption.py, vault/
- Why needed: long-running tasks complete while user may be offline — can’t wait for client to encrypt
Zero-Knowledge Password Verification
Note: “zero-knowledge” here refers specifically to the password verification flow — we verify that you know your password without ever learning it. It does NOT mean the server cannot decrypt your stored content. See encryption-architecture.md.
- Client derives
lookup_hash = SHA256(password + salt)→ sends only hash, never the plaintext password - Server locates user by
hashed_email = SHA256(email)→ the plaintext email is not used as a direct lookup key (an encrypted copy is still stored for invoicing / transactional mail) - Server verifies lookup_hash → the plaintext password is never stored
- Client decrypts master key locally to verify authentication
- Implementation: auth_login.py (server), cryptoService.ts (client)
Password + 2FA Requirement
- Password auth always requires 2FA — set up together, can’t exist independently
- If user cancels 2FA setup → password not saved
- Implementation: SettingsPassword.svelte
S3 File Access
Client Access (Presigned URLs)
GET /v1/embeds/presigned-url→ 15-min presigned URL → client fetches encrypted blob → decrypts with local AES key- On 403 (expired): auto-retry with fresh URL; in-memory blob cache prevents redundant fetches
- Implementation: embeds_api.py (endpoint), presignedUrlService.ts (client retry logic)
Skill/Task Access (Internal API)
GET /internal/s3/download?s3_key=...→ server AWS credentials → Vault unwrap → decrypt in memory- Internal-only endpoint, not exposed through public API gateway
- Implementation: internal_api.py
Audio Transcription
- Client sends
vault_wrapped_aes_key(never plaintext AES key) apps_api.pyresolvesvault_key_idfrom cache/Directus → skill unwraps via Vault- Implementation: transcribe_skill.py
Profile images use separate public-read bucket — unencrypted thumbnails, no sensitive data
Edge Cases
- At-rest compromise (cold DB / backup / cache dump): attackers get only ciphertext and hashes — no access to passwords, email plaintext, chat content, or master keys without a user credential
- Live-process compromise: an attacker with root on a running API host could in principle read plaintext that is transiently in RAM during AI processing. This is the honest limit of the posture and is why we rely on defense-in-depth (Vault, OTel redaction, minimal blast radius) for live hosts
- Presigned URL expiry: 403 → auto-retry in presignedUrlService.ts
- Stale vault keys in cache: decryption fails → request fresh data from client → re-cache. Handled in message_received_handler.py
- Multiple login methods: each wraps the same master key differently (password-derived, passkey PRF, recovery key). See passkeys.md, account-recovery.md
Security Controls Summary
| Category | Status | Documentation |
|---|---|---|
| Authentication | Zero-knowledge password verification, 2FA mandatory | Signup & Login |
| Encryption | AES-256-GCM, dual-mode | See tiers above |
| S3 Access | Private bucket + presigned URLs (15-min TTL) | See section above |
| Email Privacy | Client-side encrypted storage | Email Privacy |
| PII Anonymization | Client-side detection, placeholder replacement | PII Protection |
| Passkey Support | WebAuthn with PRF extension | Passkeys |
| Device Management | Planned: QR login, remote logout | Device Sessions |
Connected Accounts (Planned)
Status: Planned — part of Finance app milestone. Reusable pattern for any app needing user-level provider auth.
Apps can connect to external services on a per-user basis (e.g., Revolut Business, InvoiceNinja). Connected account credentials are client-side encrypted by default and follow the same lifecycle as chat messages and other settings/memories.
How It Fits the Existing Memories Pipeline
Connected accounts piggyback on the existing settings/memories infrastructure (preprocessor.py → permission request → app_settings_memories_confirmed_handler.py → Redis cache → main_processor.py). The only difference: credentials are stripped before LLM context.
Existing flow (settings/memories):
- Client sends metadata (available keys) with each message
- Preprocessing LLM suggests which keys to load
- If not cached → permission request to user → user confirms → client decrypts & sends cleartext
- Server Vault-encrypts cleartext → caches in Redis (chat-scoped, 24h TTL)
- Main processor decrypts from cache → passes full content to LLM
Connected accounts flow (same pipeline, one difference at step 5):
- Client sends metadata:
"finance-connected_accounts"in available keys - Preprocessing LLM suggests loading connected accounts
- If not cached → permission request → user confirms → client decrypts & sends full account data (names + credentials)
- Server Vault-encrypts → caches in Redis (same chat-scoped, 24h TTL)
- Main processor decrypts from cache → strips credentials → passes only display names + status to LLM
- LLM calls skill: “use Review skill with account Revolut Business EUR”
- Skill execution code (not LLM) resolves “Revolut Business EUR” → fetches full credentials from same cache → makes API calls
LLM Visibility Model
| Stage | What LLM sees | What is hidden |
|---|---|---|
| Preprocessor metadata (always) | “User has N connected accounts for Finance app” | Account names, provider types, all credentials |
| After user confirms (main processor) | Account display names, status, scopes | All credentials (tokens, URLs, keys) — stripped before LLM context |
| Skill execution (application code, not LLM) | N/A — resolves name → full credentials from cache | N/A |
The LLM never sees credentials at any stage. When the LLM calls a skill and specifies a connected account by name, the skill execution layer (application code) resolves the name to credentials from the same Vault-encrypted Redis cache.
Credential Lifecycle
Credentials follow the same lifecycle as chat messages and other settings/memories:
- Client decrypts credentials and submits them when user confirms access in a chat
- Server Vault-encrypts and caches in Redis — same chat-scoped, 24h TTL as other settings/memories
- When the chat ages out of the recent window (or 24h TTL expires), cached credentials are evicted
- Credentials are never persisted server-side in plaintext — only E2E encrypted blobs in Directus
Default: Client-Side Encrypted
sequenceDiagram
participant C as Client
participant S as Server
participant P as External Provider (e.g. Revolut)
Note over C,P: Initial OAuth Connection
C->>P: Redirect to consent screen
P->>S: Callback with auth code
S->>P: Exchange code → access_token + refresh_token
S->>C: Return refresh_token (via WebSocket)
Note over S: Server discards tokens immediately
C->>C: Encrypt refresh_token with master key
C->>S: Store encrypted blob in Directus
Note over C,P: Using a connected account in chat
C->>S: User confirms: "Yes, include my Revolut account"
C->>S: Send decrypted credentials (attached to chat context)
Note over S: Credentials held in memory (same lifecycle as chat messages)
S->>S: LLM calls skill with account name (never sees credentials)
S->>S: Skill execution code resolves name → credentials
S->>P: Exchange refresh_token → fresh access_token
S->>P: Make API calls with access_token
S->>C: Return results
Note over S: Credentials remain in chat context until chat ages out
- Credentials stored: Client-side encrypted in Directus as
connected_accountsmemory field (same encryption as chat messages) - Server access: Credentials submitted per-chat when user confirms, held in memory with same lifecycle as chat messages, discarded when chat is no longer recent
- At-rest compromise: Attacker reading a cold database, cache dump, or backup gets only ciphertext — without a user credential the external accounts cannot be reached
- Token types supported:
- OAuth refresh tokens (Revolut Business) — server exchanges for short-lived access token on each skill call
- API keys/tokens (InvoiceNinja) — used directly by skill execution code
Upgrade: Vault-Hybrid (For Workflows)
When a user enables background features (scheduled reports, webhook-triggered actions, reminders), the system upgrades credential storage:
- User is informed: “To run this workflow while you’re offline, we need to store your credentials server-side encrypted”
- On consent: credentials are additionally stored in Vault under
kv/data/users/{user_id_hash}/providers/{provider_id} - Server can then access credentials for background tasks without user being online
- User can revoke server-side storage at any time → falls back to client-only mode
Connected Accounts Memory Field Schema
# In app.yml settings_and_memories
- id: connected_accounts
type: list
schema:
properties:
provider_id: { type: string } # e.g. "revolut_business", "invoiceninja"
display_name: { type: string } # e.g. "Revolut Business (EUR)" — visible to LLM only after user confirms
connected_at: { type: integer } # Unix timestamp
scopes: { type: string } # e.g. "READ,WRITE,PAY"
status: { type: string, enum: [connected, expired, revoked] }
credentials: { type: string } # E2E encrypted JSON blob — NEVER visible to LLM
vault_enabled: { type: boolean } # true if user consented to server-side storage for workflows
Provider-Specific Connection Flows
| Provider | Auth Method | Credentials Stored (E2E) | Notes |
|---|---|---|---|
| Revolut Business | OAuth 2.0 | refresh_token, client_id |
Auth code exchanged server-side, refresh_token handed to client for encryption |
| InvoiceNinja | API Token | api_token, instance_url |
User pastes token + URL in settings UI |
Security Properties
- Default posture: Client-side encrypted — server cannot access external accounts without active user participation in an active chat session
- LLM isolation: LLM sees account names only after user confirms; credentials are resolved by application code, never in LLM context
- Chat-scoped retention: Credentials in server memory follow the same lifecycle as chat messages — discarded when chat is no longer recent
- Upgrade path: Vault-hybrid only with explicit user consent, only for features that require offline/background access
- Revocation: User can disconnect at any time → encrypted blob deleted, Vault entry removed if present
Design Assumptions
- At-rest compromise is assumed possible → all user content and credentials are stored as ciphertext, with keys wrapped in HashiCorp Vault
- Legal process targeting at-rest data → the relevant storage tier (database, backups, caches) holds only ciphertext; serving decrypted content requires live access to user credentials and Vault, not just a cold dump. We do not claim this makes us unable to comply with valid legal orders that target live processing.
- Prompt injection → defense-in-depth, minimize data exposure. See Prompt Injection
Improvement Opportunities
Improvement opportunity: Device management (QR login, remote logout) — currently planned, not implemented Improvement opportunity: Encrypted search indexes for client-side full-text search without leaking data to server
Related Docs
- Signup & Login — authentication flows
- Client-Side Encryption — client-side encryption details
- Passkeys — WebAuthn implementation
- Email Privacy — email encryption
- Device Sessions — device management
- Embeds — embed-level encryption and key wrappers
- Message Processing — dual-cache (vault vs. client encryption)