This document defines the threat model, encryption decisions, and security contracts for the Psyche server. It is a permanent reference. Do not alter the threat model or encryption algorithm without an explicit architectural decision recorded here.
Psyche is a local-first server. It runs on hardware the user controls. The primary threat is:
An attacker who gains read access to the filesystem — through a stolen drive, a compromised backup, a physical attack, or privilege escalation on the host machine — and attempts to extract sensitive information about the user's media consumption, identity, and personal data.
- Attacker reads the SQLite database file directly.
- Attacker reads generated thumbnail files directly.
- Attacker reads server log files.
- Attacker reads configuration files (env vars exported to disk).
- Attacker intercepts traffic between Psyche and remote APIs (MITM on LAN).
- Attacker has write access and can modify the database or files. (Integrity protection via authenticated encryption partially mitigates this; full integrity checking is a future concern.)
- Attacker compromises the running Psyche process (memory access, RCE).
- Network-level attacks from the internet — Psyche is not designed to be exposed directly to the internet without a reverse proxy and TLS.
- Side-channel attacks (timing, power, cache).
| Data | Encrypted | Notes |
|---|---|---|
| User email | Yes | Phase 2. Stored as ciphertext in users table. |
| User display name | Yes | Phase 2. |
| Password hash | No | Argon2id output is already non-reversible. |
| Anime / episode titles | Yes | Phase 2. The title reveals what the user watches. |
| Watch history | Yes | Phase 2. Highly sensitive. |
| Ratings and reviews | Yes | Phase 2. |
| Generated thumbnails | Yes | Phase 2. Stored encrypted; decrypted on serve. |
| Integration credentials | Yes | Phase 2. API keys, OAuth tokens. |
| JWT secret | No | Lives in env var, not in the database. |
| Media files (video/audio) | No | See rationale below. |
| Downloaded subtitle files | No | Files remain as-is on disk. |
| Server logs | No | Logs must not contain PII. Enforced by convention. |
Encrypting multi-gigabyte video files in real time is impractical:
- A 25 GB Blu-ray remux would require full AES-GCM decryption on every seek operation, destroying streaming performance.
- The attack surface for media files is low: a video file reveals its content only to someone who plays it, and the filename is protected by the scanner storing metadata in the encrypted database rather than relying on filenames.
- The user is expected to protect media files through filesystem permissions and full-disk encryption at the OS level (e.g. LUKS, BitLocker, FileVault).
The sensitive surface is metadata — what you watch, when you watch it, how you rate it, who you are. That is what Psyche encrypts.
AES-256-GCM (Authenticated Encryption with Associated Data).
Properties that matter for this use case:
- Authenticated: the tag detects tampering. A modified ciphertext will fail decryption with an explicit error, not silently produce garbage plaintext.
- 256-bit key: resists brute force at current and projected computational levels.
- Nonce-per-record: each encrypted value in the database uses a unique random 96-bit nonce, preventing nonce reuse across records.
- No padding oracle: GCM is a stream mode; no padding is required.
Storage format for encrypted database columns:
[ 12 bytes nonce ][ N bytes ciphertext ][ 16 bytes GCM tag ]
Stored as BLOB in SQLite. Total overhead per value: 28 bytes.
Derived at server startup from the PSYCHE_MASTER_KEY environment variable
using Argon2id:
- Memory: 64 MB
- Iterations: 3
- Parallelism: 1
- Output: 32 bytes (256-bit master key)
- Salt: a fixed server-specific salt stored in the database on first run.
The master key exists only in memory for the lifetime of the server process. It is never written to disk.
Each user has a unique 256-bit encryption key generated at registration time
using a cryptographically secure random number generator (OsRng).
The per-user key is stored in the users table as an AES-256-GCM encrypted
blob, using the master key as the encryption key.
users.encryption_key = AES-256-GCM(master_key, random_per_user_key)
When a request arrives for a protected resource:
- The JWT is validated — the
user_idclaim is extracted. - The encrypted per-user key is fetched from the database.
- The master key decrypts the per-user key in memory.
- The per-user key decrypts the requested data.
- The plaintext is used for the response.
- The plaintext is discarded. It is never cached or stored.
The per-user key itself is held in memory only for the duration of the decryption operation.
Argon2id via the argon2 crate (version 0.5).
Parameters (OWASP recommended minimums):
- Memory: 19 MB (19456 KiB)
- Iterations: 2
- Parallelism: 1
- Output length: 32 bytes
- Salt: random 16 bytes per password, stored with the hash
The full Argon2id PHC string (including algorithm, version, parameters, salt,
and hash) is stored in the users.password_hash column. No additional
encoding is required.
HS256 (HMAC-SHA256) via the jsonwebtoken crate (version 9).
Claims:
{
"sub": "42",
"role": "viewer",
"exp": 1234567890,
"iat": 1234567890
}sub: user ID as a string.role:"admin"or"viewer".exp: expiry timestamp. Tokens expire after 24 hours by default.iat: issued-at timestamp.
The signing secret is the PSYCHE_JWT_SECRET environment variable. It must
be at least 32 bytes of random data. Psyche will refuse to start if the
variable is missing or shorter than 32 bytes.
Refresh tokens are not implemented in Phase 1. They are a future concern.
Server logs must never contain:
- Plaintext passwords or password fragments.
- JWT token values.
- Encryption keys or key material.
- User PII (email, display name) in plaintext.
- Full file paths that reveal library structure to log readers.
Use structured logging (tracing). Log user_id integers, not usernames.
Log episode_id integers, not file paths. When in doubt, omit the value.
Psyche does not terminate TLS itself. It is designed to run behind a reverse proxy (Nginx, Caddy, Traefik) that handles TLS.
If accessed over a local network without TLS, the user accepts the risk. Documentation will recommend Caddy as the simplest TLS-terminating reverse proxy for home server users.
Encryption at rest is a Phase 2 concern.
Phase 0 and Phase 1 store all data in plaintext. The schema is designed so that adding encryption in Phase 2 requires only:
- Adding a
encryption_key BLOBcolumn to theuserstable. - Migrating plaintext columns to
BLOBcolumns. - Backfilling existing rows.
Do not attempt to implement encryption in Phase 0 or Phase 1. Do not design the schema in a way that makes the Phase 2 migration impossible.