Files
autarch/core/keystore.py

260 lines
8.8 KiB
Python
Raw Normal View History

"""
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