Add Android forensics, IOC threat-intel DB, Compose companion; scrub secrets from configs
This commit is contained in:
259
core/keystore.py
Normal file
259
core/keystore.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user