Crypto + tokens
- app/core/security.py: Argon2id PasswordHasher (time_cost=2, memory_cost=
64 MiB, parallelism=2) + opaque-token SHA-256 helpers (raw token shown
once, only the hash lives in the DB).
- app/core/jwt_tokens.py: HS256, claims iss/sub/type/jti/iat/exp. Access
1h, refresh 30d.
Services
- services/auth.py: login, refresh with token rotation + reuse-detection
chain revoke, logout (idempotent), change_password (forces logout-all).
- services/invitations.py: create, preview, accept, revoke. Default 7d TTL.
- services/bootstrap.py: seeds the 3 system groups (admin/redteam/blueteam),
consumes the install token, attaches the first user to admin.
- core/install_token.py: mints, persists in settings, marks consumed,
regenerate hook for /diag/reset.
API
- POST /setup (consume install token, create 1st admin) + GET /setup
(status).
- POST /auth/{login,refresh,logout,change-password} + GET /auth/me.
- POST /invitations + GET /invitations + GET /invitations/preview/<token> +
POST /invitations/accept/<token> + POST /invitations/<id>/revoke.
- POST /diag/reset: test-only kill switch (truncate auth tables + mint
fresh install token). Allowed in dev too (with WARNING log) so the e2e
suite can run against a make-up stack; production locked out.
Middleware
- @require_auth populates g.current_user (snapshot dataclass, session
closed before request handler runs).
- @require_perm(*codes): atomic perm union check; admin group bypasses.
Perm catalogue lands in M3, scaffolding here.
- flask-limiter: 10/min/IP on /auth/login & /auth/refresh, 5/min on
/auth/change-password & /setup, 10–20/min on invitation endpoints.
Disabled in APP_ENV=test.
CLI
- flask --app app.cli metamorph print-install-token [--force]
- flask --app app.cli metamorph seed-mitre (M4 placeholder)
Refresh cookie metamorph_refresh: HttpOnly + Secure (localhost is a secure
context for modern browsers) + SameSite=Strict + Path=/api/v1/auth/.
Email validation: app.api._validation.Email permissive RFC-shape regex so
internal TLDs (.local/.corp/.test) are accepted — pydantic.EmailStr's
deliverability check is too strict for red-team labs.
Frontend
- lib/{api,auth}.ts: access token in module memory, refresh cookie,
automatic 401-retry via /auth/refresh, useAuth() hook.
- components/{Layout,RequireAuth}.tsx + ui/{TextField,Alert}.tsx.
- pages/{Login,Setup,Register,Profile}.
Testing
- tests/test_auth_flow.py: 15 integration tests (24 backend total).
- e2e/tests/m2-auth.spec.ts: 8 Playwright tests (20 e2e total).
- tasks/testing-m2.md.
DoD: make test-api → 24 passed, make e2e → 20 passed; spec-reviewer pass
applied (Secure unconditional, refresh limit 10/min/IP).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
63 lines
1.7 KiB
Python
63 lines
1.7 KiB
Python
"""Password hashing and constant-time secret hashing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
|
|
from argon2 import PasswordHasher
|
|
from argon2.exceptions import VerifyMismatchError
|
|
|
|
# Argon2id with moderate cost. `time_cost=2`, `memory_cost=64MiB`, `parallelism=2`
|
|
# is well above OWASP minimums while staying snappy on a Debian small VM.
|
|
_hasher = PasswordHasher(
|
|
time_cost=2,
|
|
memory_cost=64 * 1024,
|
|
parallelism=2,
|
|
hash_len=32,
|
|
salt_len=16,
|
|
)
|
|
|
|
|
|
def hash_password(plaintext: str) -> str:
|
|
return _hasher.hash(plaintext)
|
|
|
|
|
|
def verify_password(stored_hash: str, plaintext: str) -> bool:
|
|
"""Constant-time verification. Returns False on mismatch, never raises."""
|
|
try:
|
|
return _hasher.verify(stored_hash, plaintext)
|
|
except VerifyMismatchError:
|
|
return False
|
|
except Exception: # corrupted hash or unsupported parameters
|
|
return False
|
|
|
|
|
|
def needs_rehash(stored_hash: str) -> bool:
|
|
"""True when Argon2 parameters have evolved since the hash was created."""
|
|
try:
|
|
return _hasher.check_needs_rehash(stored_hash)
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
# === Opaque-token helpers (refresh tokens, invitation tokens) ===
|
|
#
|
|
# We never store the raw token in DB — only its SHA-256. Comparison uses
|
|
# `hmac.compare_digest` to dodge timing attacks. Tokens are URL-safe base64.
|
|
|
|
TOKEN_BYTES = 48 # 384 bits of entropy → 64 chars b64url
|
|
|
|
|
|
def generate_opaque_token() -> str:
|
|
return secrets.token_urlsafe(TOKEN_BYTES)
|
|
|
|
|
|
def hash_opaque_token(token: str) -> str:
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def verify_opaque_token(token: str, stored_hash: str) -> bool:
|
|
return hmac.compare_digest(hash_opaque_token(token), stored_hash)
|