Global MFA toggle in Security settings, QR code setup on first login, 6-digit TOTP verification on subsequent logins. Azure AD users exempt. Admins can reset user MFA. TOTP secrets encrypted at rest with Fernet. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
132 lines
3.8 KiB
Python
132 lines
3.8 KiB
Python
"""Security utilities — password hashing (bcrypt), token encryption (Fernet), TOTP."""
|
|
|
|
import os
|
|
import secrets
|
|
|
|
import pyotp
|
|
from cryptography.fernet import Fernet
|
|
from passlib.context import CryptContext
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Password hashing (bcrypt)
|
|
# ---------------------------------------------------------------------------
|
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
|
|
def hash_password(plain: str) -> str:
|
|
"""Hash a plaintext password with bcrypt.
|
|
|
|
Args:
|
|
plain: The plaintext password.
|
|
|
|
Returns:
|
|
Bcrypt hash string.
|
|
"""
|
|
return pwd_context.hash(plain)
|
|
|
|
|
|
def verify_password(plain: str, hashed: str) -> bool:
|
|
"""Verify a plaintext password against a bcrypt hash.
|
|
|
|
Args:
|
|
plain: The plaintext password to check.
|
|
hashed: The stored bcrypt hash.
|
|
|
|
Returns:
|
|
True if the password matches.
|
|
"""
|
|
return pwd_context.verify(plain, hashed)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fernet encryption for secrets (NPM token, relay secrets, etc.)
|
|
# ---------------------------------------------------------------------------
|
|
def _get_fernet() -> Fernet:
|
|
"""Derive a Fernet key from the application SECRET_KEY.
|
|
|
|
The SECRET_KEY from the environment is used as the basis. We pad/truncate
|
|
it to produce a valid 32-byte URL-safe-base64 key that Fernet requires.
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
|
|
secret = os.environ.get("SECRET_KEY", "change-me-in-production")
|
|
# Derive a stable 32-byte key via SHA-256
|
|
key_bytes = hashlib.sha256(secret.encode()).digest()
|
|
fernet_key = base64.urlsafe_b64encode(key_bytes)
|
|
return Fernet(fernet_key)
|
|
|
|
|
|
def encrypt_value(plaintext: str) -> str:
|
|
"""Encrypt a string value with Fernet.
|
|
|
|
Args:
|
|
plaintext: Value to encrypt.
|
|
|
|
Returns:
|
|
Encrypted string (base64-encoded Fernet token).
|
|
"""
|
|
f = _get_fernet()
|
|
return f.encrypt(plaintext.encode()).decode()
|
|
|
|
|
|
def decrypt_value(ciphertext: str) -> str:
|
|
"""Decrypt a Fernet-encrypted string.
|
|
|
|
Args:
|
|
ciphertext: Encrypted value.
|
|
|
|
Returns:
|
|
Original plaintext string.
|
|
"""
|
|
f = _get_fernet()
|
|
return f.decrypt(ciphertext.encode()).decode()
|
|
|
|
|
|
def generate_relay_secret() -> str:
|
|
"""Generate a cryptographically secure relay secret.
|
|
|
|
Returns:
|
|
A 32-character hex string.
|
|
"""
|
|
return secrets.token_hex(16)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TOTP (Time-based One-Time Password)
|
|
# ---------------------------------------------------------------------------
|
|
def generate_totp_secret() -> str:
|
|
"""Generate a new random TOTP secret (base32-encoded)."""
|
|
return pyotp.random_base32()
|
|
|
|
|
|
def verify_totp(secret: str, code: str) -> bool:
|
|
"""Verify a 6-digit TOTP code against a secret.
|
|
|
|
Allows a window of +/- 1 interval (30s) to account for clock drift.
|
|
"""
|
|
totp = pyotp.TOTP(secret)
|
|
return totp.verify(code, valid_window=1)
|
|
|
|
|
|
def generate_totp_uri(secret: str, username: str, issuer: str = "NetBird MSP") -> str:
|
|
"""Generate an otpauth:// URI for QR code generation."""
|
|
totp = pyotp.TOTP(secret)
|
|
return totp.provisioning_uri(name=username, issuer_name=issuer)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Misc key generation
|
|
# ---------------------------------------------------------------------------
|
|
def generate_datastore_encryption_key() -> str:
|
|
"""Generate a base64-encoded 32-byte key for NetBird DataStoreEncryptionKey.
|
|
|
|
NetBird management (Go) expects standard base64 decoding to exactly 32 bytes.
|
|
|
|
Returns:
|
|
A standard base64-encoded string representing 32 random bytes.
|
|
"""
|
|
import base64
|
|
|
|
return base64.b64encode(secrets.token_bytes(32)).decode()
|