""" AUTARCH Encrypted Keystore Per-user encrypted storage for API keys, passwords, and credentials. Stores secrets in keystore.xml in the user data directory using AES-256-CBC encryption from the existing vault module. Secrets are separated by user account so multi-user deployments don't leak across accounts. The keystore file format is XML for human-inspectability of the structure (though all values are encrypted). Each entry has: - A unique key name - The encrypted value (base64-encoded) - The user who owns it - A timestamp - An optional category (api_key, password, token, certificate, etc.) Usage: from core.keystore import get_keystore ks = get_keystore() # Store a secret for the current user ks.set('claude_api_key', 'sk-ant-...', category='api_key') # Retrieve it key = ks.get('claude_api_key') # Returns '' if not set # List all keys for the current user ks.keys() # ['claude_api_key', 'openai_api_key'] # Store for a specific user ks.set('shodan_key', 'abc123', user='admin', category='api_key') # Delete ks.delete('old_key') """ import base64 import hashlib import json import logging import os import time import xml.etree.ElementTree as ET from pathlib import Path from typing import Dict, List, Optional from xml.dom import minidom _log = logging.getLogger('autarch.keystore') # Import encryption functions from the existing vault from core.vault import _derive_key, _encrypt, _decrypt def _get_default_user() -> str: """Get the current user identity for key ownership.""" from core.paths import get_user_profile_path profile_path = get_user_profile_path() if profile_path.exists(): try: with open(profile_path) as f: profile = json.load(f) return profile.get('username', 'default') except Exception: pass return 'default' class Keystore: """Encrypted per-user secrets keystore backed by XML.""" def __init__(self, keystore_path: Path = None, master_password: str = ''): from core.paths import get_keystore_path self._path = keystore_path or get_keystore_path() self._master = master_password self._salt = None self._entries: Dict[str, Dict] = {} # key -> {value, user, category, timestamp, salt} self._load() def _load(self): """Load and decrypt the keystore from disk.""" if not self._path.exists(): _log.info(f'No keystore found at {self._path}, starting fresh') return try: tree = ET.parse(str(self._path)) root = tree.getroot() self._salt = base64.b64decode(root.get('salt', '')) for entry in root.findall('entry'): key_name = entry.get('name', '') if not key_name: continue self._entries[key_name] = { 'encrypted_value': entry.get('value', ''), 'user': entry.get('user', 'default'), 'category': entry.get('category', ''), 'timestamp': entry.get('timestamp', ''), 'iv': entry.get('iv', ''), } _log.info(f'Loaded keystore with {len(self._entries)} entries') except Exception as e: _log.error(f'Failed to load keystore: {e}') def _save(self): """Encrypt and save the keystore to disk.""" try: root = ET.Element('keystore') root.set('version', '1') if self._salt is None: self._salt = os.urandom(32) root.set('salt', base64.b64encode(self._salt).decode()) root.set('updated', str(int(time.time()))) for key_name, data in sorted(self._entries.items()): entry = ET.SubElement(root, 'entry') entry.set('name', key_name) entry.set('value', data.get('encrypted_value', '')) entry.set('iv', data.get('iv', '')) entry.set('user', data.get('user', 'default')) entry.set('category', data.get('category', '')) entry.set('timestamp', data.get('timestamp', '')) # Pretty-print the XML rough = ET.tostring(root, encoding='unicode') parsed = minidom.parseString(rough) pretty = parsed.toprettyxml(indent=' ', encoding='utf-8') self._path.parent.mkdir(parents=True, exist_ok=True) with open(self._path, 'wb') as f: f.write(pretty) _log.info(f'Saved keystore with {len(self._entries)} entries') except Exception as e: _log.error(f'Failed to save keystore: {e}') def set(self, key: str, value: str, user: str = None, category: str = ''): """Store an encrypted secret.""" if user is None: user = _get_default_user() if self._salt is None: self._salt = os.urandom(32) # Derive encryption key from salt + master password enc_key = _derive_key(self._salt, self._master) # Encrypt the value iv, ciphertext = _encrypt(value.encode('utf-8'), enc_key) self._entries[key] = { 'encrypted_value': base64.b64encode(ciphertext).decode(), 'iv': base64.b64encode(iv).decode(), 'user': user, 'category': category, 'timestamp': str(int(time.time())), } self._save() _log.info(f'Stored secret: {key} (user={user}, category={category})') def get(self, key: str, user: str = None) -> str: """Retrieve a decrypted secret. Returns '' if not found.""" if user is None: user = _get_default_user() entry = self._entries.get(key) if not entry: return '' # Check user ownership if entry['user'] != user and entry['user'] != 'shared': _log.warning(f'Access denied: {key} belongs to {entry["user"]}, not {user}') return '' try: enc_key = _derive_key(self._salt, self._master) iv = base64.b64decode(entry['iv']) ciphertext = base64.b64decode(entry['encrypted_value']) plaintext = _decrypt(iv, ciphertext, enc_key) return plaintext.decode('utf-8') except Exception as e: _log.error(f'Failed to decrypt {key}: {e}') return '' def delete(self, key: str, user: str = None): """Delete a secret.""" if user is None: user = _get_default_user() entry = self._entries.get(key) if entry and (entry['user'] == user or entry['user'] == 'shared'): del self._entries[key] self._save() _log.info(f'Deleted secret: {key}') def keys(self, user: str = None, category: str = None) -> List[str]: """List key names for a user, optionally filtered by category.""" if user is None: user = _get_default_user() result = [] for key, data in self._entries.items(): if data['user'] != user and data['user'] != 'shared': continue if category and data.get('category', '') != category: continue result.append(key) return sorted(result) def get_all_for_user(self, user: str = None) -> Dict[str, str]: """Get all decrypted secrets for a user as a dict.""" if user is None: user = _get_default_user() return {k: self.get(k, user) for k in self.keys(user)} def get_metadata(self, key: str) -> Optional[Dict]: """Get metadata (user, category, timestamp) for a key without decrypting.""" entry = self._entries.get(key) if not entry: return None return { 'user': entry['user'], 'category': entry.get('category', ''), 'timestamp': entry.get('timestamp', ''), } def migrate_from_vault(self): """Import secrets from the old vault.enc into the new keystore.""" from core.vault import get_vault vault = get_vault() migrated = 0 for key in vault.keys(): value = vault.get(key) if value and key not in self._entries: # Guess category from key name category = 'api_key' if 'api_key' in key else 'credential' self.set(key, value, user='default', category=category) migrated += 1 if migrated: _log.info(f'Migrated {migrated} secrets from vault to keystore') return migrated # Singleton _keystore_instance: Optional[Keystore] = None def get_keystore(master_password: str = '') -> Keystore: """Get the global keystore instance.""" global _keystore_instance if _keystore_instance is None: _keystore_instance = Keystore(master_password=master_password) return _keystore_instance