Skip to content

Storage & encryption

Flat JSON, atomic tmp+rename, mode 0o600. The encryption layer is invisible to the call site.

CryptoStore

server/cryptostore.go is the chokepoint. Two singletons:

var (
    crypto *CryptoStore   // encrypts new writes; reads decrypt when needed
    plain  *CryptoStore   // never encrypts; reads decrypt if file is ciphertext (legacy migration)
)

crypto.Write(path, data, mode) AEAD-encrypts with AES-256-GCM. crypto.Read(path) sniffs a magic header; decrypts when present, returns bytes as-is when absent. That lets legacy plaintext files auto-migrate on the next write without a flag day.

Stores that should be encrypted on disk go through crypto:

  • conversations (server/store.go)
  • credentials (server/auth.go)
  • connection profiles (server/connections.go, server/image_connections.go)
  • API tokens (server/tokens.go)

Stores that stay plaintext go through plain:

  • character cards + avatars
  • personas
  • attachments
  • model files

Plaintext is deliberate for browseability (Finder / Quick Look / file managers) and because the content is share-format anyway (Tavern cards are public-format).

The storage key

One AES-256 key per install. Generated on first boot, stored in the OS keyring under storage-key:

OS Keyring
macOS Keychain
Linux libsecret
Windows Credential Manager

The key never touches the filesystem in plaintext form. Lose access to the keyring → ciphertext files become unreadable.

pluma -export-storage-key <path> passphrase-wraps the key (age-style) and writes it to <path> for backup. pluma -import-storage-key <path> unwraps it back into the keyring on a new machine. The passphrase is prompted at runtime, not stored.

Atomic writes

Every persistent write follows the same pattern:

tmp := path + ".tmp"
os.WriteFile(tmp, data, 0o600)
os.Rename(tmp, path)

POSIX rename(2) is atomic on the same filesystem. Crash mid-write leaves either the pre-write content or the new content — never a half-written file. The .tmp orphan from a crashed write gets overwritten on the next attempt.

API keys

Connection profiles (connections.json) and image-backend profiles (image_connections.json) store the profile metadata (name, base URL, compat flag) but NOT the API key. The key lives in the OS keyring under connection:<id> / image:<id> namespaces, queried at use-site.

Keyring write is probed at startup so an unsupported environment (headless Linux without libsecret) fails loudly instead of failing the first time someone saves a key.

Decryption timing

Trigger What gets decrypted
GET /api/conversations (FE mount) Every conversation file in <datadir>/conversations/. The list endpoint returns full message arrays, so there's no metadata-only path.
GET /api/conversations/{id} One file. Currently unused — FE gets everything from List.
POST /api/chat The active conversation, to read history before forwarding upstream.

No in-memory plaintext cache. Each request re-decrypts from disk. For single-user local-first use this is fine; volume per Pluma instance is in the hundreds of conversations, not thousands. A metadata-only list endpoint is queued under smelt-0wc if perf becomes a concern.

Limits + caps

server/limits.go defines the body-size caps applied via http.MaxBytesReader on every JSON handler:

Cap Bytes Used for
bodyAuth 64 KiB WebAuthn envelopes, connection saves, URL imports, small JSON
bodyCard 2 MiB Tavern card v3 with realistic lore, ComfyUI workflow saves
bodyChat 16 MiB Conversation save / message edit / draft PATCH
bodyImageGen 50 MiB Image generate with inline avatar_base64
bodyVoiceSample 60 MiB Voice library uploads (audio + small video)
voiceURLDownloadCap 200 MiB Per-source cap on yt-dlp / direct-fetch downloads

A hostile or runaway peer can't drag Pluma into unbounded allocation. The relevant cap fires before the JSON decoder starts.