gns3-server/gns3server/utils/encryption.py
YueGuobin 7c2083d227 feat: update .gitignore to track project context and development docs
- Add exception for .claude/development.md to allow tracking development documentation
- Comment out PROJECT_CONTEXT.md exclusion to enable version control of project context file
- Create PROJECT_CONTEXT.md with comprehensive project overview for AI assistant support
- Document project structure, AI copilot data flow, SSE event types, and code standards
- Provide flake8 static analysis guidelines and common error fixes
2026-03-09 21:46:58 +08:00

172 lines
5.1 KiB
Python

#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Encryption utilities for sensitive data like API keys.
Uses Fernet symmetric encryption.
"""
import os
import logging
from typing import Optional
from cryptography.fernet import Fernet
log = logging.getLogger(__name__)
# Global encryption key - will be loaded from config
_fernet: Optional[Fernet] = None
def init_encryption(secrets_dir: str) -> None:
"""
Initialize encryption by loading or generating the encryption key.
:param secrets_dir: Directory to store the encryption key
"""
global _fernet
encryption_key_path = os.path.join(secrets_dir, "gns3_encryption_key")
if not os.path.exists(encryption_key_path):
log.info(
f"No encryption key found, generating one in "
f"'{encryption_key_path}'..."
)
try:
key = Fernet.generate_key()
os.makedirs(secrets_dir, exist_ok=True)
with open(encryption_key_path, "w", encoding="utf-8") as f:
# Use Fernet's base64-encoded key format
f.write(key.decode() if isinstance(key, bytes) else key)
# Set restrictive permissions (owner read/write only)
os.chmod(encryption_key_path, 0o600)
except OSError as e:
log.error(
"Could not create encryption key file "
f"'{encryption_key_path}': {e}"
)
raise
try:
with open(encryption_key_path, encoding="utf-8") as f:
key_content = f.read().strip()
key_bytes = (
key_content.encode()
if isinstance(key_content, str)
else key_content
)
_fernet = Fernet(key_bytes)
log.debug("Encryption initialized successfully")
except OSError as e:
log.error(
f"Could not read encryption key file '{encryption_key_path}': {e}"
)
raise
def encrypt(plaintext: str) -> str:
"""
Encrypt a plaintext string.
:param plaintext: The plaintext to encrypt
:returns: Base64-encoded encrypted string
:raises RuntimeError: If encryption is not initialized
"""
if _fernet is None:
raise RuntimeError(
"Encryption not initialized. Call init_encryption() first."
)
if not plaintext:
return ""
encrypted = _fernet.encrypt(plaintext.encode())
return encrypted.decode()
def decrypt(ciphertext: str) -> str:
"""
Decrypt a ciphertext string.
:param ciphertext: The base64-encoded encrypted string
:returns: Decrypted plaintext string
:raises RuntimeError: If encryption is not initialized
:raises ValueError: If decryption fails (invalid data or wrong key)
"""
if _fernet is None:
raise RuntimeError(
"Encryption not initialized. Call init_encryption() first."
)
if not ciphertext:
return ""
try:
decrypted = _fernet.decrypt(ciphertext.encode())
return decrypted.decode()
except Exception as e:
raise ValueError(f"Decryption failed: {e}") from e
def is_encrypted(value: str) -> bool:
"""
Check if a value appears to be encrypted (heuristic).
:param value: The value to check
:returns: True if the value appears to be encrypted
"""
if not value:
return False
# Fernet encrypted data is URL-safe base64 and has a specific structure
# Check for Fernet prefix and valid format
try:
# Fernet tokens always start with 'gAAAAA' in base64
if not value.startswith('gAAAAA'):
return False
# Attempt to decode as URL-safe base64
import base64
decoded = base64.urlsafe_b64decode(value)
# Fernet tokens have a specific format (minimum 32 bytes)
return len(decoded) >= 32
except Exception:
return False
def re_encrypt(old_key_path: str, new_key_path: str) -> None:
"""
Re-encrypt data with a new key (for key rotation).
:param old_key_path: Path to the old encryption key
:param new_key_path: Path to the new encryption key
"""
# Load old key and decrypt all data
with open(old_key_path, encoding="utf-8") as f:
old_key = f.read().strip().encode()
old_fernet = Fernet(old_key)
# This would be used during a key rotation migration
# Implementation depends on how data is stored
log.warning("Key rotation not yet implemented")
# Mark old_fernet as intentionally unused for now
_ = old_fernet