Security6 of 10
Secrets
On this page
Three separate things are meant here, and they are protected differently:
- Keys you save in the app — provider API keys, MCP tokens. Encrypted at rest.
- Keys you put in
api/.env— read as ordinary environment variables. Not encrypted; protected by your filesystem, like any.env. - What reaches a process she runs — governed by an allow-list, described at the bottom.
The keystore
Keys saved through the app go into the saved_keys table of the database, encrypted with
AES-256-GCM before they are written. The stored blob is v1: followed by base64 of a 12-byte
nonce plus the ciphertext and GCM tag.
Measured: a saved value round-trips correctly; the blob carries the v1: prefix; a plaintext value
that merely starts with v1: is correctly identified as not one of ours, so it is neither stored in
the clear nor read back as unusable.
Three properties the code holds to:
- They are decrypted in memory at use time only. They are never logged and never returned to the model.
- They are never handed back over the API. The keys endpoint returns names and creation dates
only —
SELECT name, created_at FROM saved_keys. There is no route that returns a value. - If encryption is unavailable, saving fails. With the
cryptographypackage missing, the keystore raises rather than falling back to storing the secret in plaintext.
The master key
The key that encrypts the keystore is resolved in this order:
KOTOBA_MASTER_KEY, if set. Any string: base64 is decoded if it decodes to at least 32 bytes, otherwise the string is hashed to a stable 32 bytes.- A per-install key file, generated on first use.
The key file lives at ~/.kotoba/.keystore_key unless KOTOBA_KEYSTORE_KEY_FILE names another path.
Measured on a fresh directory: 32 bytes, mode 0600.
It is created with O_CREAT | O_EXCL | O_WRONLY | O_BINARY, so two processes starting together cannot
each generate a key and orphan the other's secrets — the loser adopts the winner's. O_BINARY is not
decoration: os.open is text mode on Windows, and an earlier build wrote line-feed bytes in the key
as two bytes, which cost roughly one install in eight the first secret it ever saved.
A key file that is too short is refused with an explicit log line rather than sliced and used. A key file that is too long has its first 32 bytes used, with a warning — those are the bytes everything on that install was encrypted with, so recovering the "true" key would break every secret saved since.
If the master key changes
Measured: after the master key changes, decrypting a blob written under the old one returns nothing. This is the honest failure and it is deliberate — the read path never generates a new key, because doing so made new saves work while the panel kept listing the old secret as saved and nothing could ever read it again.
Practically:
- Do not set
KOTOBA_MASTER_KEYafter saving keys without it, or the reverse. Either change orphans everything already saved. Re-enter the keys. - On ephemeral filesystems (a container that is rebuilt on deploy), set
KOTOBA_MASTER_KEYfrom your secret manager. Otherwise a redeploy loses the generated key file and takes the saved keys with it. - On a persistent local install, leaving it unset is fine.
When a blob is recognisably ours but cannot be decrypted, the error says so rather than answering "never saved".
File permissions
POSIX. The keystore key file is created at 0600. The database is set to 0600 on every
connect, not just at creation — sqlite creates a new file at 0644 minus umask, so this repairs
existing installs and undoes a permissive mode someone set. Atomic-write temporaries and the log file
are 0600 too.
kotoba doctor reads the two files and reports their actual modes rather than answering from the
platform, so a key sitting at 0644 is reported as 0644.
Windows. os.chmod(path, 0o600) there only toggles the read-only bit and writes no ACL, so the
same files get an explicit ACL instead, applied with icacls:
icacls <path> /inheritance:r /grant:r <SID>:(F)
/inheritance:r drops the ACEs the parent handed down rather than copying them, and /grant:r
replaces rather than adds. The account is named by SID, resolved once per process, not by name:
on a standalone machine that has a workgroup, the environment names the workgroup rather than the
computer, and WORKGROUP\user maps to no account, so every grant silently failed.
The WAL sidecar files (-wal, -shm, -journal) get the same treatment. On POSIX sqlite copies the
database's mode onto them; on Windows there is no mode to copy, so they are restricted explicitly, and
asked for twice, because on a fresh install the migrations are what create them.
A failed ACL grant is logged, never raised — so kotoba doctor writes a throwaway file next to the
real ones and reports whether the grant actually took. Without that, an install that kept its
inherited ACL looked exactly like one that was locked down.
What a process she runs is handed
The environment given to any child process started by the local backend is an allow-list.
Everything not named is dropped.
Base list, every platform:
PATH HOME LANG LC_ALL SHELL PYTHONPATH VIRTUAL_ENV TZ TERM
Plus, on a desktop POSIX system:
DISPLAY WAYLAND_DISPLAY XAUTHORITY XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS
XDG_CURRENT_DESKTOP XDG_SESSION_TYPE DESKTOP_SESSION KDE_FULL_SESSION
KDE_SESSION_VERSION XDG_DATA_DIRS XDG_DATA_HOME XDG_CONFIG_DIRS XDG_CONFIG_HOME
Plus, on Windows:
SYSTEMROOT WINDIR COMSPEC PATHEXT SYSTEMDRIVE HOMEDRIVE HOMEPATH USERPROFILE
APPDATA LOCALAPPDATA PROGRAMDATA PROGRAMFILES PROGRAMFILES(X86)
NUMBER_OF_PROCESSORS PROCESSOR_ARCHITECTURE OS PSMODULEPATH
Plus TMPDIR, TMP and TEMP, all pointed at ~/.kotoba/tmp.
After the allow-list, a second filter drops any surviving name containing KEY, TOKEN, SECRET,
PASSWORD, PASSWD, CREDENTIAL or AUTH. XAUTHORITY collides with that and is exempted by
name — it holds the path to the X11 cookie, which a child under the same HOME could already find.
Measured on a real process whose parent held OPENAI_API_KEY, ELEVENLABS_API_KEY and
GITHUB_PERSONAL_ACCESS_TOKEN: none reached the child. The child's environment was fifteen names.
Say what the desktop names add. None of them is a credential, but together they are reach: an
approved command can paint a window, read the clipboard, and talk to the session bus — which on a
typical desktop includes the keyring. They are there because without them "open that PDF" fails with
no DISPLAY environment variable specified. The approval gate is the only thing in front of that.
Under KOTOBA_SANDBOX=docker the question does not arise: docker exec uses the image's environment,
and nothing from the host is forwarded.
Secrets you type during a task
Two tools handle values you type, and neither shows them to the model.
ask_secret— a one-time value, typed into a masked box. It goes to an in-memory store, never the database, never the transcript. The model receives a{{secret:NAME}}placeholder; the real value is substituted at the point of use and cleared when the work finishes.request_credential/get_credential— her own reusable credentials, stored encrypted under acred:namespace. The namespace is enforced in code, not by convention:get_credentialprepends the prefix to whatever name the model supplies, so a name aiming at a provider key or an MCP token resolves to a row that does not exist. Listing filters to the same prefix. It returns the same placeholder, never a value.
Where things live
| What | Where |
|---|---|
| Conversations and the audit trail | ~/.kotoba/kotoba.db — or api/kotoba.db when running from a clone |
| Settings | ~/.kotoba/settings.yaml |
| Saved keys | encrypted inside that database |
| The master key that decrypts them | ~/.kotoba/.keystore_key |
| Files she works with | ~/.kotoba/files |
| Scratch space | ~/.kotoba/tmp |
| Logs | ~/.kotoba/cli.log |
