From 7617a805954903571e387b8399880a702b4fbcd8 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 17 May 2026 12:50:24 +0200 Subject: [PATCH] feat(vault): API key storage in vault only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vault/reader.py: get_key() reads from ~/.pyra/vault/secrets/api_keys.json - vault/writer.py: set_key(), delete_key() — only writer callable from setup - Both call assert_safe_path() as defense-in-depth - Keys file stays chmod 400; temporarily 600 during write then locked again - Config.yaml never touched by either module Co-Authored-By: Claude Sonnet 4.6 --- src/pyra/vault/reader.py | 21 ++++++++++++++++++++ src/pyra/vault/writer.py | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/pyra/vault/reader.py create mode 100644 src/pyra/vault/writer.py diff --git a/src/pyra/vault/reader.py b/src/pyra/vault/reader.py new file mode 100644 index 0000000..3b184ec --- /dev/null +++ b/src/pyra/vault/reader.py @@ -0,0 +1,21 @@ +import json +from pathlib import Path + +from pyra.security.boundaries import assert_safe_path +from pyra.utils.paths import pyra_home, safe_chmod + +_KEYS_FILE = pyra_home() / "vault" / "secrets" / "api_keys.json" + + +def get_key(provider_id: str) -> str | None: + """Read an API key from the vault. Never exposed to the AI.""" + assert_safe_path(_KEYS_FILE) # defense-in-depth + + if not _KEYS_FILE.exists(): + return None + + # Ensure permissions remain tight before reading + safe_chmod(_KEYS_FILE, 0o400) + + data: dict = json.loads(_KEYS_FILE.read_text()) + return data.get(provider_id) diff --git a/src/pyra/vault/writer.py b/src/pyra/vault/writer.py new file mode 100644 index 0000000..47fe267 --- /dev/null +++ b/src/pyra/vault/writer.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path + +from pyra.security.boundaries import assert_safe_path +from pyra.utils.paths import ensure_dir, pyra_home, safe_chmod + +_KEYS_FILE = pyra_home() / "vault" / "secrets" / "api_keys.json" + + +def set_key(provider_id: str, api_key: str) -> None: + """Store an API key in the vault. Called only by the setup wizard.""" + assert_safe_path(_KEYS_FILE) # defense-in-depth + + ensure_dir(_KEYS_FILE.parent, 0o700) + + # Temporarily make writable to update + if _KEYS_FILE.exists(): + safe_chmod(_KEYS_FILE, 0o600) + data: dict = json.loads(_KEYS_FILE.read_text()) + else: + data = {} + + data[provider_id] = api_key + + _KEYS_FILE.write_text(json.dumps(data, indent=2)) + safe_chmod(_KEYS_FILE, 0o400) + + +def delete_key(provider_id: str) -> bool: + """Remove an API key from the vault. Returns True if key existed.""" + assert_safe_path(_KEYS_FILE) + + if not _KEYS_FILE.exists(): + return False + + safe_chmod(_KEYS_FILE, 0o600) + data: dict = json.loads(_KEYS_FILE.read_text()) + existed = provider_id in data + data.pop(provider_id, None) + _KEYS_FILE.write_text(json.dumps(data, indent=2)) + safe_chmod(_KEYS_FILE, 0o400) + return existed