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