260 lines
8.8 KiB
Python
260 lines
8.8 KiB
Python
"""
|
|
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
|