backend redesign
This commit is contained in:
406
backend.old/src/secrets_manager/store.py
Normal file
406
backend.old/src/secrets_manager/store.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
Encrypted secrets store with master password protection.
|
||||
|
||||
The secrets are stored in an encrypted file, with the encryption key derived
|
||||
from a master password using Argon2id. The master password can be changed
|
||||
without re-encrypting all secrets.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from cryptography.fernet import InvalidToken
|
||||
|
||||
from .crypto import (
|
||||
generate_salt,
|
||||
derive_key_from_password,
|
||||
encrypt_data,
|
||||
decrypt_data,
|
||||
create_verification_hash,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
class SecretsStoreError(Exception):
|
||||
"""Base exception for secrets store errors."""
|
||||
pass
|
||||
|
||||
|
||||
class SecretsStoreLocked(SecretsStoreError):
|
||||
"""Raised when trying to access secrets while store is locked."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMasterPassword(SecretsStoreError):
|
||||
"""Raised when master password is incorrect."""
|
||||
pass
|
||||
|
||||
|
||||
class SecretsStore:
|
||||
"""
|
||||
Encrypted secrets store with master password protection.
|
||||
|
||||
Usage:
|
||||
# Initialize (first time)
|
||||
store = SecretsStore()
|
||||
store.initialize("my-secure-password")
|
||||
|
||||
# Unlock
|
||||
store = SecretsStore()
|
||||
store.unlock("my-secure-password")
|
||||
|
||||
# Access secrets
|
||||
api_key = store.get("ANTHROPIC_API_KEY")
|
||||
store.set("NEW_SECRET", "secret-value")
|
||||
|
||||
# Change master password
|
||||
store.change_master_password("my-secure-password", "new-password")
|
||||
|
||||
# Lock when done
|
||||
store.lock()
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[Path] = None):
|
||||
"""
|
||||
Initialize secrets store.
|
||||
|
||||
Args:
|
||||
data_dir: Directory for secrets files (defaults to backend/data)
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to backend/data
|
||||
backend_root = Path(__file__).parent.parent.parent
|
||||
data_dir = backend_root / "data"
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.master_key_file = self.data_dir / ".master.key"
|
||||
self.secrets_file = self.data_dir / "secrets.enc"
|
||||
|
||||
# Runtime state
|
||||
self._encryption_key: Optional[bytes] = None
|
||||
self._secrets: Optional[Dict[str, Any]] = None
|
||||
|
||||
@property
|
||||
def is_initialized(self) -> bool:
|
||||
"""Check if the secrets store has been initialized."""
|
||||
return self.master_key_file.exists()
|
||||
|
||||
@property
|
||||
def is_unlocked(self) -> bool:
|
||||
"""Check if the secrets store is currently unlocked."""
|
||||
return self._encryption_key is not None
|
||||
|
||||
def initialize(self, master_password: str) -> None:
|
||||
"""
|
||||
Initialize the secrets store with a master password.
|
||||
|
||||
This should only be called once when setting up the store.
|
||||
|
||||
Args:
|
||||
master_password: The master password to protect the secrets
|
||||
|
||||
Raises:
|
||||
SecretsStoreError: If store is already initialized
|
||||
"""
|
||||
if self.is_initialized:
|
||||
raise SecretsStoreError(
|
||||
"Secrets store is already initialized. "
|
||||
"Use unlock() to access it or change_master_password() to change the password."
|
||||
)
|
||||
|
||||
# Generate a new random salt
|
||||
salt = generate_salt()
|
||||
|
||||
# Derive encryption key
|
||||
encryption_key = derive_key_from_password(master_password, salt)
|
||||
|
||||
# Create verification hash
|
||||
verification_hash = create_verification_hash(master_password, salt)
|
||||
|
||||
# Store salt and verification hash
|
||||
master_key_data = {
|
||||
"salt": salt.hex(),
|
||||
"verification": verification_hash,
|
||||
}
|
||||
|
||||
self.master_key_file.write_text(json.dumps(master_key_data, indent=2))
|
||||
|
||||
# Set restrictive permissions (owner read/write only)
|
||||
os.chmod(self.master_key_file, stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
# Initialize empty secrets
|
||||
self._encryption_key = encryption_key
|
||||
self._secrets = {}
|
||||
self._save_secrets()
|
||||
|
||||
print(f"✓ Secrets store initialized at {self.secrets_file}")
|
||||
|
||||
def unlock(self, master_password: str) -> None:
|
||||
"""
|
||||
Unlock the secrets store with the master password.
|
||||
|
||||
Args:
|
||||
master_password: The master password
|
||||
|
||||
Raises:
|
||||
SecretsStoreError: If store is not initialized
|
||||
InvalidMasterPassword: If password is incorrect
|
||||
"""
|
||||
if not self.is_initialized:
|
||||
raise SecretsStoreError(
|
||||
"Secrets store is not initialized. Call initialize() first."
|
||||
)
|
||||
|
||||
# Load salt and verification hash
|
||||
master_key_data = json.loads(self.master_key_file.read_text())
|
||||
salt = bytes.fromhex(master_key_data["salt"])
|
||||
verification_hash = master_key_data["verification"]
|
||||
|
||||
# Verify password
|
||||
if not verify_password(master_password, salt, verification_hash):
|
||||
raise InvalidMasterPassword("Invalid master password")
|
||||
|
||||
# Derive encryption key
|
||||
encryption_key = derive_key_from_password(master_password, salt)
|
||||
|
||||
# Load and decrypt secrets
|
||||
if self.secrets_file.exists():
|
||||
try:
|
||||
encrypted_data = self.secrets_file.read_bytes()
|
||||
decrypted_data = decrypt_data(encrypted_data, encryption_key)
|
||||
self._secrets = json.loads(decrypted_data.decode('utf-8'))
|
||||
except InvalidToken:
|
||||
raise InvalidMasterPassword("Failed to decrypt secrets (invalid password)")
|
||||
except json.JSONDecodeError as e:
|
||||
raise SecretsStoreError(f"Corrupted secrets file: {e}")
|
||||
else:
|
||||
# No secrets file yet (fresh initialization)
|
||||
self._secrets = {}
|
||||
|
||||
self._encryption_key = encryption_key
|
||||
print(f"✓ Secrets store unlocked ({len(self._secrets)} secrets)")
|
||||
|
||||
def lock(self) -> None:
|
||||
"""Lock the secrets store (clear decrypted data from memory)."""
|
||||
self._encryption_key = None
|
||||
self._secrets = None
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a secret value.
|
||||
|
||||
Args:
|
||||
key: Secret key name
|
||||
default: Default value if key doesn't exist
|
||||
|
||||
Returns:
|
||||
Secret value or default
|
||||
|
||||
Raises:
|
||||
SecretsStoreLocked: If store is locked
|
||||
"""
|
||||
if not self.is_unlocked:
|
||||
raise SecretsStoreLocked("Secrets store is locked. Call unlock() first.")
|
||||
|
||||
return self._secrets.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a secret value.
|
||||
|
||||
Args:
|
||||
key: Secret key name
|
||||
value: Secret value (must be JSON-serializable)
|
||||
|
||||
Raises:
|
||||
SecretsStoreLocked: If store is locked
|
||||
"""
|
||||
if not self.is_unlocked:
|
||||
raise SecretsStoreLocked("Secrets store is locked. Call unlock() first.")
|
||||
|
||||
self._secrets[key] = value
|
||||
self._save_secrets()
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Delete a secret.
|
||||
|
||||
Args:
|
||||
key: Secret key name
|
||||
|
||||
Returns:
|
||||
True if secret existed and was deleted, False otherwise
|
||||
|
||||
Raises:
|
||||
SecretsStoreLocked: If store is locked
|
||||
"""
|
||||
if not self.is_unlocked:
|
||||
raise SecretsStoreLocked("Secrets store is locked. Call unlock() first.")
|
||||
|
||||
if key in self._secrets:
|
||||
del self._secrets[key]
|
||||
self._save_secrets()
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_keys(self) -> list[str]:
|
||||
"""
|
||||
List all secret keys.
|
||||
|
||||
Returns:
|
||||
List of secret keys
|
||||
|
||||
Raises:
|
||||
SecretsStoreLocked: If store is locked
|
||||
"""
|
||||
if not self.is_unlocked:
|
||||
raise SecretsStoreLocked("Secrets store is locked. Call unlock() first.")
|
||||
|
||||
return list(self._secrets.keys())
|
||||
|
||||
def change_master_password(self, current_password: str, new_password: str) -> None:
|
||||
"""
|
||||
Change the master password.
|
||||
|
||||
This re-encrypts the secrets with a new key derived from the new password.
|
||||
|
||||
Args:
|
||||
current_password: Current master password
|
||||
new_password: New master password
|
||||
|
||||
Raises:
|
||||
InvalidMasterPassword: If current password is incorrect
|
||||
"""
|
||||
# ALWAYS verify current password before changing
|
||||
# Load salt and verification hash
|
||||
if not self.is_initialized:
|
||||
raise SecretsStoreError(
|
||||
"Secrets store is not initialized. Call initialize() first."
|
||||
)
|
||||
|
||||
master_key_data = json.loads(self.master_key_file.read_text())
|
||||
salt = bytes.fromhex(master_key_data["salt"])
|
||||
verification_hash = master_key_data["verification"]
|
||||
|
||||
# Verify current password is correct
|
||||
if not verify_password(current_password, salt, verification_hash):
|
||||
raise InvalidMasterPassword("Invalid current password")
|
||||
|
||||
# Unlock if needed to access secrets
|
||||
was_unlocked = self.is_unlocked
|
||||
if not was_unlocked:
|
||||
# Store is locked, so unlock with current password
|
||||
# (we already verified it above, so this will succeed)
|
||||
encryption_key = derive_key_from_password(current_password, salt)
|
||||
|
||||
# Load and decrypt secrets
|
||||
if self.secrets_file.exists():
|
||||
encrypted_data = self.secrets_file.read_bytes()
|
||||
decrypted_data = decrypt_data(encrypted_data, encryption_key)
|
||||
self._secrets = json.loads(decrypted_data.decode('utf-8'))
|
||||
else:
|
||||
self._secrets = {}
|
||||
|
||||
self._encryption_key = encryption_key
|
||||
|
||||
# Generate new salt
|
||||
new_salt = generate_salt()
|
||||
|
||||
# Derive new encryption key
|
||||
new_encryption_key = derive_key_from_password(new_password, new_salt)
|
||||
|
||||
# Create new verification hash
|
||||
new_verification_hash = create_verification_hash(new_password, new_salt)
|
||||
|
||||
# Update master key file
|
||||
master_key_data = {
|
||||
"salt": new_salt.hex(),
|
||||
"verification": new_verification_hash,
|
||||
}
|
||||
self.master_key_file.write_text(json.dumps(master_key_data, indent=2))
|
||||
os.chmod(self.master_key_file, stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
# Re-encrypt secrets with new key
|
||||
old_key = self._encryption_key
|
||||
self._encryption_key = new_encryption_key
|
||||
self._save_secrets()
|
||||
|
||||
print("✓ Master password changed successfully")
|
||||
|
||||
# Lock if it wasn't unlocked before
|
||||
if not was_unlocked:
|
||||
self.lock()
|
||||
|
||||
def _save_secrets(self) -> None:
|
||||
"""Save secrets to encrypted file."""
|
||||
if not self.is_unlocked:
|
||||
raise SecretsStoreLocked("Cannot save while locked")
|
||||
|
||||
# Serialize secrets to JSON
|
||||
secrets_json = json.dumps(self._secrets, indent=2)
|
||||
secrets_bytes = secrets_json.encode('utf-8')
|
||||
|
||||
# Encrypt
|
||||
encrypted_data = encrypt_data(secrets_bytes, self._encryption_key)
|
||||
|
||||
# Write to file
|
||||
self.secrets_file.write_bytes(encrypted_data)
|
||||
|
||||
# Set restrictive permissions
|
||||
os.chmod(self.secrets_file, stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
def export_encrypted(self, output_path: Path) -> None:
|
||||
"""
|
||||
Export encrypted secrets to a file (for backup).
|
||||
|
||||
Args:
|
||||
output_path: Path to export file
|
||||
|
||||
Raises:
|
||||
SecretsStoreError: If secrets file doesn't exist
|
||||
"""
|
||||
if not self.secrets_file.exists():
|
||||
raise SecretsStoreError("No secrets to export")
|
||||
|
||||
import shutil
|
||||
shutil.copy2(self.secrets_file, output_path)
|
||||
print(f"✓ Encrypted secrets exported to {output_path}")
|
||||
|
||||
def import_encrypted(self, input_path: Path, master_password: str) -> None:
|
||||
"""
|
||||
Import encrypted secrets from a file.
|
||||
|
||||
This will verify the password can decrypt the import before replacing
|
||||
the current secrets.
|
||||
|
||||
Args:
|
||||
input_path: Path to import file
|
||||
master_password: Master password for the current store
|
||||
|
||||
Raises:
|
||||
InvalidMasterPassword: If password doesn't work with import
|
||||
"""
|
||||
if not self.is_unlocked:
|
||||
self.unlock(master_password)
|
||||
|
||||
# Try to decrypt the imported file with current key
|
||||
try:
|
||||
encrypted_data = Path(input_path).read_bytes()
|
||||
decrypted_data = decrypt_data(encrypted_data, self._encryption_key)
|
||||
imported_secrets = json.loads(decrypted_data.decode('utf-8'))
|
||||
except InvalidToken:
|
||||
raise InvalidMasterPassword(
|
||||
"Cannot decrypt imported secrets with current master password"
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
raise SecretsStoreError(f"Corrupted import file: {e}")
|
||||
|
||||
# Replace secrets
|
||||
self._secrets = imported_secrets
|
||||
self._save_secrets()
|
||||
|
||||
print(f"✓ Imported {len(self._secrets)} secrets from {input_path}")
|
||||
Reference in New Issue
Block a user