Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

Security6 of 10

Secrets

About 6 minutes to read

On this page

Three separate things are meant here, and they are protected differently:

  1. Keys you save in the app — provider API keys, MCP tokens. Encrypted at rest.
  2. Keys you put in api/.env — read as ordinary environment variables. Not encrypted; protected by your filesystem, like any .env.
  3. 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 cryptography package 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:

  1. 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.
  2. 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_KEY after 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_KEY from 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 a cred: namespace. The namespace is enforced in code, not by convention: get_credential prepends 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

WhatWhere
Conversations and the audit trail~/.kotoba/kotoba.db — or api/kotoba.db when running from a clone
Settings~/.kotoba/settings.yaml
Saved keysencrypted 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