backend redesign
This commit is contained in:
40
backend.old/src/secrets_manager/__init__.py
Normal file
40
backend.old/src/secrets_manager/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Encrypted secrets management with master password protection.
|
||||
|
||||
This module provides secure storage for sensitive configuration like API keys,
|
||||
using Argon2id for password-based key derivation and Fernet (AES-256) for encryption.
|
||||
|
||||
Basic usage:
|
||||
from secrets_manager import SecretsStore
|
||||
|
||||
# First time setup
|
||||
store = SecretsStore()
|
||||
store.initialize("my-master-password")
|
||||
store.set("ANTHROPIC_API_KEY", "sk-ant-...")
|
||||
|
||||
# Later usage
|
||||
store = SecretsStore()
|
||||
store.unlock("my-master-password")
|
||||
api_key = store.get("ANTHROPIC_API_KEY")
|
||||
|
||||
Command-line interface:
|
||||
python -m secrets_manager.cli init
|
||||
python -m secrets_manager.cli set KEY VALUE
|
||||
python -m secrets_manager.cli get KEY
|
||||
python -m secrets_manager.cli list
|
||||
python -m secrets_manager.cli change-password
|
||||
"""
|
||||
|
||||
from .store import (
|
||||
SecretsStore,
|
||||
SecretsStoreError,
|
||||
SecretsStoreLocked,
|
||||
InvalidMasterPassword,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SecretsStore",
|
||||
"SecretsStoreError",
|
||||
"SecretsStoreLocked",
|
||||
"InvalidMasterPassword",
|
||||
]
|
||||
374
backend.old/src/secrets_manager/cli.py
Normal file
374
backend.old/src/secrets_manager/cli.py
Normal file
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Command-line interface for managing the encrypted secrets store.
|
||||
|
||||
Usage:
|
||||
python -m secrets.cli init # Initialize new secrets store
|
||||
python -m secrets.cli set KEY VALUE # Set a secret
|
||||
python -m secrets.cli get KEY # Get a secret
|
||||
python -m secrets.cli delete KEY # Delete a secret
|
||||
python -m secrets.cli list # List all secret keys
|
||||
python -m secrets.cli change-password # Change master password
|
||||
python -m secrets.cli export FILE # Export encrypted backup
|
||||
python -m secrets.cli import FILE # Import encrypted backup
|
||||
python -m secrets.cli migrate-from-env # Migrate secrets from .env file
|
||||
"""
|
||||
import sys
|
||||
import argparse
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
|
||||
from .store import SecretsStore, SecretsStoreError, InvalidMasterPassword
|
||||
|
||||
|
||||
def get_password(prompt: str = "Master password: ", confirm: bool = False) -> str:
|
||||
"""
|
||||
Securely get password from user.
|
||||
|
||||
Args:
|
||||
prompt: Password prompt
|
||||
confirm: If True, ask for confirmation
|
||||
|
||||
Returns:
|
||||
Password string
|
||||
"""
|
||||
password = getpass.getpass(prompt)
|
||||
|
||||
if confirm:
|
||||
confirm_password = getpass.getpass("Confirm password: ")
|
||||
if password != confirm_password:
|
||||
print("Error: Passwords do not match", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return password
|
||||
|
||||
|
||||
def cmd_init(args):
|
||||
"""Initialize a new secrets store."""
|
||||
store = SecretsStore()
|
||||
|
||||
if store.is_initialized:
|
||||
print("Error: Secrets store is already initialized", file=sys.stderr)
|
||||
print(f"Location: {store.secrets_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
password = get_password("Create master password: ", confirm=True)
|
||||
|
||||
if len(password) < 8:
|
||||
print("Error: Password must be at least 8 characters", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
store.initialize(password)
|
||||
print(f"Secrets store initialized at {store.secrets_file}")
|
||||
|
||||
|
||||
def cmd_set(args):
|
||||
"""Set a secret value."""
|
||||
store = SecretsStore()
|
||||
|
||||
if not store.is_initialized:
|
||||
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
password = get_password()
|
||||
|
||||
try:
|
||||
store.unlock(password)
|
||||
except InvalidMasterPassword:
|
||||
print("Error: Invalid master password", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
store.set(args.key, args.value)
|
||||
print(f"✓ Secret '{args.key}' saved")
|
||||
|
||||
|
||||
def cmd_get(args):
|
||||
"""Get a secret value."""
|
||||
store = SecretsStore()
|
||||
|
||||
if not store.is_initialized:
|
||||
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
password = get_password()
|
||||
|
||||
try:
|
||||
store.unlock(password)
|
||||
except InvalidMasterPassword:
|
||||
print("Error: Invalid master password", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
value = store.get(args.key)
|
||||
if value is None:
|
||||
print(f"Error: Secret '{args.key}' not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Print to stdout (can be captured)
|
||||
print(value)
|
||||
|
||||
|
||||
def cmd_delete(args):
|
||||
"""Delete a secret."""
|
||||
store = SecretsStore()
|
||||
|
||||
if not store.is_initialized:
|
||||
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
password = get_password()
|
||||
|
||||
try:
|
||||
store.unlock(password)
|
||||
except InvalidMasterPassword:
|
||||
print("Error: Invalid master password", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if store.delete(args.key):
|
||||
print(f"✓ Secret '{args.key}' deleted")
|
||||
else:
|
||||
print(f"Error: Secret '{args.key}' not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
"""List all secret keys."""
|
||||
store = SecretsStore()
|
||||
|
||||
if not store.is_initialized:
|
||||
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
password = get_password()
|
||||
|
||||
try:
|
||||
store.unlock(password)
|
||||
except InvalidMasterPassword:
|
||||
print("Error: Invalid master password", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
keys = store.list_keys()
|
||||
|
||||
if not keys:
|
||||
print("No secrets stored")
|
||||
else:
|
||||
print(f"Stored secrets ({len(keys)}):")
|
||||
for key in sorted(keys):
|
||||
# Show key and value length for verification
|
||||
value = store.get(key)
|
||||
value_str = str(value)
|
||||
value_preview = value_str[:50] + "..." if len(value_str) > 50 else value_str
|
||||
print(f" {key}: {value_preview}")
|
||||
|
||||
|
||||
def cmd_change_password(args):
|
||||
"""Change the master password."""
|
||||
store = SecretsStore()
|
||||
|
||||
if not store.is_initialized:
|
||||
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
current_password = get_password("Current master password: ")
|
||||
new_password = get_password("New master password: ", confirm=True)
|
||||
|
||||
if len(new_password) < 8:
|
||||
print("Error: Password must be at least 8 characters", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
store.change_master_password(current_password, new_password)
|
||||
except InvalidMasterPassword:
|
||||
print("Error: Invalid current password", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_export(args):
|
||||
"""Export encrypted secrets to a backup file."""
|
||||
store = SecretsStore()
|
||||
|
||||
if not store.is_initialized:
|
||||
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
output_path = Path(args.file)
|
||||
|
||||
if output_path.exists() and not args.force:
|
||||
print(f"Error: File {output_path} already exists. Use --force to overwrite.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
store.export_encrypted(output_path)
|
||||
except SecretsStoreError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_import(args):
|
||||
"""Import encrypted secrets from a backup file."""
|
||||
store = SecretsStore()
|
||||
|
||||
if not store.is_initialized:
|
||||
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
input_path = Path(args.file)
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"Error: File {input_path} does not exist", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
password = get_password()
|
||||
|
||||
try:
|
||||
store.import_encrypted(input_path, password)
|
||||
except InvalidMasterPassword:
|
||||
print("Error: Invalid master password or incompatible backup", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except SecretsStoreError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_migrate_from_env(args):
|
||||
"""Migrate secrets from .env file to encrypted store."""
|
||||
store = SecretsStore()
|
||||
|
||||
if not store.is_initialized:
|
||||
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Look for .env file
|
||||
backend_root = Path(__file__).parent.parent.parent
|
||||
env_file = backend_root / ".env"
|
||||
|
||||
if not env_file.exists():
|
||||
print(f"Error: .env file not found at {env_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
password = get_password()
|
||||
|
||||
try:
|
||||
store.unlock(password)
|
||||
except InvalidMasterPassword:
|
||||
print("Error: Invalid master password", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Parse .env file (simple parser - doesn't handle all edge cases)
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
|
||||
with open(env_file) as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Parse KEY=VALUE format
|
||||
if '=' not in line:
|
||||
print(f"Warning: Skipping invalid line {line_num}: {line}", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
# Remove quotes if present
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
elif value.startswith("'") and value.endswith("'"):
|
||||
value = value[1:-1]
|
||||
|
||||
# Check if key already exists
|
||||
existing = store.get(key)
|
||||
if existing is not None:
|
||||
print(f"Warning: Secret '{key}' already exists, skipping", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
store.set(key, value)
|
||||
print(f"✓ Migrated: {key}")
|
||||
migrated += 1
|
||||
|
||||
print(f"\nMigration complete: {migrated} secrets migrated, {skipped} skipped")
|
||||
|
||||
if not args.keep_env:
|
||||
# Ask for confirmation before deleting .env
|
||||
confirm = input(f"\nDelete {env_file}? [y/N]: ").strip().lower()
|
||||
if confirm == 'y':
|
||||
env_file.unlink()
|
||||
print(f"✓ Deleted {env_file}")
|
||||
else:
|
||||
print(f"Kept {env_file} (consider deleting it manually)")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage encrypted secrets store",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
||||
subparsers.required = True
|
||||
|
||||
# init
|
||||
parser_init = subparsers.add_parser('init', help='Initialize new secrets store')
|
||||
parser_init.set_defaults(func=cmd_init)
|
||||
|
||||
# set
|
||||
parser_set = subparsers.add_parser('set', help='Set a secret value')
|
||||
parser_set.add_argument('key', help='Secret key name')
|
||||
parser_set.add_argument('value', help='Secret value')
|
||||
parser_set.set_defaults(func=cmd_set)
|
||||
|
||||
# get
|
||||
parser_get = subparsers.add_parser('get', help='Get a secret value')
|
||||
parser_get.add_argument('key', help='Secret key name')
|
||||
parser_get.set_defaults(func=cmd_get)
|
||||
|
||||
# delete
|
||||
parser_delete = subparsers.add_parser('delete', help='Delete a secret')
|
||||
parser_delete.add_argument('key', help='Secret key name')
|
||||
parser_delete.set_defaults(func=cmd_delete)
|
||||
|
||||
# list
|
||||
parser_list = subparsers.add_parser('list', help='List all secret keys')
|
||||
parser_list.set_defaults(func=cmd_list)
|
||||
|
||||
# change-password
|
||||
parser_change = subparsers.add_parser('change-password', help='Change master password')
|
||||
parser_change.set_defaults(func=cmd_change_password)
|
||||
|
||||
# export
|
||||
parser_export = subparsers.add_parser('export', help='Export encrypted backup')
|
||||
parser_export.add_argument('file', help='Output file path')
|
||||
parser_export.add_argument('--force', action='store_true', help='Overwrite existing file')
|
||||
parser_export.set_defaults(func=cmd_export)
|
||||
|
||||
# import
|
||||
parser_import = subparsers.add_parser('import', help='Import encrypted backup')
|
||||
parser_import.add_argument('file', help='Input file path')
|
||||
parser_import.set_defaults(func=cmd_import)
|
||||
|
||||
# migrate-from-env
|
||||
parser_migrate = subparsers.add_parser('migrate-from-env', help='Migrate from .env file')
|
||||
parser_migrate.add_argument('--keep-env', action='store_true', help='Keep .env file after migration')
|
||||
parser_migrate.set_defaults(func=cmd_migrate_from_env)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
args.func(args)
|
||||
except KeyboardInterrupt:
|
||||
print("\nAborted", file=sys.stderr)
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
144
backend.old/src/secrets_manager/crypto.py
Normal file
144
backend.old/src/secrets_manager/crypto.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Cryptographic utilities for secrets management.
|
||||
|
||||
Uses Argon2id for password-based key derivation and Fernet for encryption.
|
||||
"""
|
||||
import os
|
||||
import secrets as secrets_module
|
||||
from typing import Tuple
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.low_level import hash_secret_raw, Type
|
||||
from cryptography.fernet import Fernet
|
||||
import base64
|
||||
|
||||
|
||||
# Argon2id parameters (OWASP recommended for password-based KDF)
|
||||
# These provide strong defense against GPU/ASIC attacks
|
||||
ARGON2_TIME_COST = 3 # iterations
|
||||
ARGON2_MEMORY_COST = 65536 # 64 MB
|
||||
ARGON2_PARALLELISM = 4 # threads
|
||||
ARGON2_HASH_LENGTH = 32 # bytes (256 bits for Fernet key)
|
||||
ARGON2_SALT_LENGTH = 16 # bytes (128 bits)
|
||||
|
||||
|
||||
def generate_salt() -> bytes:
|
||||
"""Generate a cryptographically secure random salt."""
|
||||
return secrets_module.token_bytes(ARGON2_SALT_LENGTH)
|
||||
|
||||
|
||||
def derive_key_from_password(password: str, salt: bytes) -> bytes:
|
||||
"""
|
||||
Derive an encryption key from a password using Argon2id.
|
||||
|
||||
Args:
|
||||
password: The master password
|
||||
salt: The salt (must be consistent for the same password to work)
|
||||
|
||||
Returns:
|
||||
32-byte key suitable for Fernet encryption
|
||||
"""
|
||||
password_bytes = password.encode('utf-8')
|
||||
|
||||
# Use Argon2id (hybrid mode - best of Argon2i and Argon2d)
|
||||
raw_hash = hash_secret_raw(
|
||||
secret=password_bytes,
|
||||
salt=salt,
|
||||
time_cost=ARGON2_TIME_COST,
|
||||
memory_cost=ARGON2_MEMORY_COST,
|
||||
parallelism=ARGON2_PARALLELISM,
|
||||
hash_len=ARGON2_HASH_LENGTH,
|
||||
type=Type.ID # Argon2id
|
||||
)
|
||||
|
||||
return raw_hash
|
||||
|
||||
|
||||
def create_fernet(key: bytes) -> Fernet:
|
||||
"""
|
||||
Create a Fernet cipher instance from a raw key.
|
||||
|
||||
Args:
|
||||
key: 32-byte raw key from Argon2id
|
||||
|
||||
Returns:
|
||||
Fernet instance for encryption/decryption
|
||||
"""
|
||||
# Fernet requires a URL-safe base64-encoded 32-byte key
|
||||
fernet_key = base64.urlsafe_b64encode(key)
|
||||
return Fernet(fernet_key)
|
||||
|
||||
|
||||
def encrypt_data(data: bytes, key: bytes) -> bytes:
|
||||
"""
|
||||
Encrypt data using Fernet (AES-256-CBC).
|
||||
|
||||
Args:
|
||||
data: Raw bytes to encrypt
|
||||
key: 32-byte encryption key
|
||||
|
||||
Returns:
|
||||
Encrypted data (includes IV and auth tag)
|
||||
"""
|
||||
fernet = create_fernet(key)
|
||||
return fernet.encrypt(data)
|
||||
|
||||
|
||||
def decrypt_data(encrypted_data: bytes, key: bytes) -> bytes:
|
||||
"""
|
||||
Decrypt data using Fernet.
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted bytes from encrypt_data
|
||||
key: 32-byte encryption key (must match encryption key)
|
||||
|
||||
Returns:
|
||||
Decrypted raw bytes
|
||||
|
||||
Raises:
|
||||
cryptography.fernet.InvalidToken: If decryption fails (wrong key/corrupted data)
|
||||
"""
|
||||
fernet = create_fernet(key)
|
||||
return fernet.decrypt(encrypted_data)
|
||||
|
||||
|
||||
def create_verification_hash(password: str, salt: bytes) -> str:
|
||||
"""
|
||||
Create a verification hash to check if a password is correct.
|
||||
|
||||
This is NOT for storing the password - it's for verifying the password
|
||||
unlocks the correct key without trying to decrypt the entire secrets file.
|
||||
|
||||
Args:
|
||||
password: The master password
|
||||
salt: The salt used for key derivation
|
||||
|
||||
Returns:
|
||||
Base64-encoded hash for verification
|
||||
"""
|
||||
# Derive key and hash it again for verification
|
||||
key = derive_key_from_password(password, salt)
|
||||
|
||||
# Simple hash of the key for verification (not security critical since
|
||||
# the key itself is already derived from Argon2id)
|
||||
verification = base64.b64encode(key[:16]).decode('ascii')
|
||||
|
||||
return verification
|
||||
|
||||
|
||||
def verify_password(password: str, salt: bytes, verification_hash: str) -> bool:
|
||||
"""
|
||||
Verify a password against a verification hash.
|
||||
|
||||
Args:
|
||||
password: Password to verify
|
||||
salt: Salt used for key derivation
|
||||
verification_hash: Expected verification hash
|
||||
|
||||
Returns:
|
||||
True if password is correct, False otherwise
|
||||
"""
|
||||
computed_hash = create_verification_hash(password, salt)
|
||||
|
||||
# Constant-time comparison to prevent timing attacks
|
||||
return secrets_module.compare_digest(computed_hash, verification_hash)
|
||||
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