Skip to content

Security: enrell/psyche

Security

docs/SECURITY.md

Psyche — Security

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.


Threat Model

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.

In Scope

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

Out of Scope (Phase 1)

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

What Is Encrypted at Rest

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.

Rationale: Media Files Are Not Encrypted

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.


Encryption Algorithm

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.


Key Model

Master Key

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.

Per-User Keys

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)

Key Derivation on Request

When a request arrives for a protected resource:

  1. The JWT is validated — the user_id claim is extracted.
  2. The encrypted per-user key is fetched from the database.
  3. The master key decrypts the per-user key in memory.
  4. The per-user key decrypts the requested data.
  5. The plaintext is used for the response.
  6. 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.


Password Hashing

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.


JWT

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.


Logging Security Contract

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.


TLS

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 Implementation Phase

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:

  1. Adding a encryption_key BLOB column to the users table.
  2. Migrating plaintext columns to BLOB columns.
  3. 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.

There aren’t any published security advisories