"""Nova IdP auth Lambda — sign-up / sign-in / session (REQ-333, REQ-334). Invoked via a Function URL (IAM auth) by the Nova CLI and consumer pipelines. Mirrors the ``contract_ingestor.py`` pattern: lazy ``boto3.resource`` DynamoDB singleton, env-var table names, ``NOVA_LAMBDA_LOCAL_BYPASS`` for local testing, ``__main__`` CLI block for dual-use (REQ-329). ## Argon2id password hashing (REQ-334, D-228, C-7.2) Passwords are hashed with Argon2id via ``argon2-cffi``: PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1) These are the OWASP minimum parameters (t=3, m=65536 KiB, p=1). Lambda memory **MUST be ≥ 512 MB** (Argon2id memory_cost ~64 MiB + runtime overhead). **D-228 (amended) — fail-closed:** there is no maintained pure-Python Argon2 implementation; a pure-Python crypto fallback is a liability (weaker hashing, violates INV-16's spirit). If the ``argon2`` C extension fails to import, the Lambda **fails closed** — ``_ARGON2_AVAILABLE`` is set ``False`` at cold-start, and :func:`hash_password` / :func:`verify_password` raise ``Argon2UnavailableError``. The handler catches this and returns **HTTP 503** (``{"error": "argon2_unavailable"}``) — **no pure-Python fallback, no weak hash, no crash.** This is verified by the explicit ``test_argon2_fail_closed`` test (C-1.2). ## No raw passwords anywhere (INV-16) Raw passwords are NEVER: * written to DynamoDB (only ``password_hash`` is stored), * logged (the handler never logs the password argument), * put in traces / env vars / X-Ray segments. Audit events (``auth.sign_up``, ``auth.sign_in``, ``auth.session_created``) are emitted to stderr as JSON; they carry the ``user_id`` / ``email`` but **never** the password. """ from __future__ import annotations import datetime import json import os import sys import uuid import boto3 # --------------------------------------------------------------------------- # Argon2id — fail-closed import (REQ-334, D-228, C-7.2) # --------------------------------------------------------------------------- # # try-import the C extension. If it fails (missing abi3 wheel, wrong # glibc, etc.), _ARGON2_AVAILABLE becomes False and hash/verify raise # Argon2UnavailableError. The handler returns 503. NO pure-Python fallback. _ARGON2_AVAILABLE = False _PasswordHasher = None try: # pragma: no cover - import success path covered by round-trip test from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError _PasswordHasher = PasswordHasher _ARGON2_AVAILABLE = True except ImportError: # pragma: no cover - exercised via mock in tests _ARGON2_AVAILABLE = False # Define a stand-in so `verify_password` can raise the right type # even when argon2 isn't importable. VerifyMismatchError is only # raised by verify() which itself raises Argon2UnavailableError first. class VerifyMismatchError(Exception): """Raised by verify_password when the password does not match.""" class Argon2UnavailableError(Exception): """Raised when the Argon2 C extension is unavailable (D-228 fail-closed). The handler catches this and returns HTTP 503 — no pure-Python fallback, no weak hash. """ # OWASP-minimum Argon2id parameters (C-7.2): # time_cost=3, memory_cost=65536 KiB (64 MiB), parallelism=1 _ARGON2_TIME_COST = 3 _ARGON2_MEMORY_COST = 65536 # KiB _ARGON2_PARALLELISM = 1 def _get_hasher(): """Return a PasswordHasher configured with the OWASP-min params. Raises Argon2UnavailableError if the C extension is not loaded. """ if not _ARGON2_AVAILABLE or _PasswordHasher is None: raise Argon2UnavailableError( "argon2 C extension unavailable — refusing to hash with a " "weak fallback (D-228 fail-closed)" ) return _PasswordHasher( time_cost=_ARGON2_TIME_COST, memory_cost=_ARGON2_MEMORY_COST, parallelism=_ARGON2_PARALLELISM, ) def hash_password(password: str) -> str: """Hash a password with Argon2id (OWASP-min params). Returns the Argon2id hash string (includes the salt + params). Raises: Argon2UnavailableError: if the ``argon2`` C extension is not importable (D-228 fail-closed — NO pure-Python fallback). """ if not _ARGON2_AVAILABLE: raise Argon2UnavailableError( "argon2 C extension unavailable — refusing to hash (D-228)" ) # NOTE: the password argument is NEVER logged. Do not add debug # prints here that include `password`. return _get_hasher().hash(password) def verify_password(password: str, hash_str: str) -> bool: """Verify a password against an Argon2id hash. Returns ``True`` if the password matches. Raises: Argon2UnavailableError: if the ``argon2`` C extension is not importable. VerifyMismatchError: if the password does not match the hash. """ if not _ARGON2_AVAILABLE: raise Argon2UnavailableError( "argon2 C extension unavailable — refusing to verify (D-228)" ) # argon2.PasswordHasher().verify raises VerifyMismatchError on # mismatch (and InvalidHash on a malformed hash). We let those # propagate; the handler maps them to 401 / 500. _get_hasher().verify(hash_str, password) return True # --------------------------------------------------------------------------- # Config (env-var table names, mirroring contract_ingestor.py) # --------------------------------------------------------------------------- USERS_TABLE = os.environ.get("NOVA_USERS_TABLE", "nova-users") SESSIONS_TABLE = os.environ.get("NOVA_SESSIONS_TABLE", "nova-sessions") PASSWORD_RESETS_TABLE = os.environ.get( "NOVA_PASSWORD_RESETS_TABLE", "nova-password-resets" ) # Session lifetime (seconds). Default 24h. SESSION_TTL_SECONDS = int(os.environ.get("NOVA_SESSION_TTL_SECONDS", "86400")) # Password-reset token lifetime (seconds). Default 15 min. RESET_TTL_SECONDS = int(os.environ.get("NOVA_RESET_TTL_SECONDS", "900")) _dynamodb = None def _get_dynamodb(): """Lazy boto3 DynamoDB resource singleton (mirrors contract_ingestor).""" global _dynamodb if _dynamodb is None: _dynamodb = boto3.resource("dynamodb") return _dynamodb def _iso8601_now() -> str: return datetime.datetime.now(datetime.timezone.utc).strftime( "%Y-%m-%dT%H:%M:%SZ" ) def _epoch_now() -> int: return int(datetime.datetime.now(datetime.timezone.utc).timestamp()) def _emit_audit(event_type: str, **fields) -> None: """Emit an audit event to stderr as JSON (never includes passwords).""" payload = {"event": event_type, "ts": _iso8601_now(), **fields} # Defense-in-depth: scrub any field literally named 'password' or # 'password_hash' value from the audit payload (they should never be # passed here, but a stray kwarg would leak — INV-16). for _k in ("password", "new_password", "old_password"): payload.pop(_k, None) sys.stderr.write(json.dumps(payload, sort_keys=True) + "\n") sys.stderr.flush() # --------------------------------------------------------------------------- # Business logic (sign_up / sign_in / create_session / reset flows) # --------------------------------------------------------------------------- def _require(fields, payload): """Validate required fields; raise ValueError (→ 400) if missing.""" for f in fields: if f not in payload or payload[f] in (None, ""): raise ValueError(f"missing field: {f}") def _lookup_user_by_email(email: str): """Query nova-users GSI1 (email-index) → return the user item or None.""" table = _get_dynamodb().Table(USERS_TABLE) resp = table.query( IndexName="email-index", KeyConditionExpression="email = :e", ExpressionAttributeValues={":e": email}, Limit=1, ) items = resp.get("Items", []) return items[0] if items else None def sign_up(payload): """Create a new user. Fails closed (503) if argon2 is unavailable. Payload: { email, password, owner, roles } Writes to nova-users: PK user_id (uuid4), email, password_hash, owner, roles, created_at. The raw password is NEVER stored. """ _require(("email", "password", "owner", "roles"), payload) if not _ARGON2_AVAILABLE: raise Argon2UnavailableError("argon2 unavailable") email = payload["email"] password = payload["password"] owner = payload["owner"] roles = payload["roles"] if not isinstance(roles, list): raise ValueError("roles must be a list") # Duplicate-email check → 409. if _lookup_user_by_email(email) is not None: raise _DuplicateEmailError(email) user_id = str(uuid.uuid4()) password_hash = hash_password(password) # fail-closed here created_at = _iso8601_now() item = { "user_id": user_id, "email": email, "password_hash": password_hash, "owner": owner, "roles": roles, "created_at": created_at, } table = _get_dynamodb().Table(USERS_TABLE) table.put_item(TableName=USERS_TABLE, Item=item) _emit_audit("auth.sign_up", user_id=user_id, email=email) return { "status": "ok", "action": "sign_up", "user_id": user_id, "email": email, "created_at": created_at, } class _DuplicateEmailError(Exception): """Raised when sign_up is called with an already-registered email → 409.""" def __init__(self, email: str): self.email = email super().__init__(f"email already registered: {email}") def create_session(user_id: str) -> str: """Create a session row in nova-sessions; return the session_id. TTL: expires_at = now + SESSION_TTL_SECONDS (epoch seconds). """ session_id = str(uuid.uuid4()) now = _epoch_now() expires_at = now + SESSION_TTL_SECONDS created_at = _iso8601_now() table = _get_dynamodb().Table(SESSIONS_TABLE) table.put_item( TableName=SESSIONS_TABLE, Item={ "session_id": session_id, "user_id": user_id, "expires_at": expires_at, "created_at": created_at, }, ) _emit_audit("auth.session_created", user_id=user_id, session_id=session_id) return session_id def sign_in(payload): """Sign in by email + password → return a session_id. On wrong password → raises VerifyMismatchError (→ 401). On unknown email → raises _UnknownUserError (→ 401, same code to avoid user-enumeration via timing — the message is generic). On argon2 unavailable → Argon2UnavailableError (→ 503). """ _require(("email", "password"), payload) if not _ARGON2_AVAILABLE: raise Argon2UnavailableError("argon2 unavailable") email = payload["email"] password = payload["password"] user = _lookup_user_by_email(email) if user is None: # Generic 401 — do not reveal whether the email is registered # (user-enumeration defense). raise _UnknownUserError("invalid credentials") try: verify_password(password, user["password_hash"]) except VerifyMismatchError: raise _UnknownUserError("invalid credentials") session_id = create_session(user["user_id"]) _emit_audit("auth.sign_in", user_id=user["user_id"], email=email) return { "status": "ok", "action": "sign_in", "user_id": user["user_id"], "session_id": session_id, } class _UnknownUserError(Exception): """Generic 'invalid credentials' — 401 (no user enumeration).""" def request_password_reset(payload): """Generate a reset token (uuid4) → write to nova-password-resets (15 min TTL). Returns the token directly (in a real system this would be emailed; for v1.28 it is returned so tests / the CLI can drive reset_password). """ _require(("email",), payload) email = payload["email"] user = _lookup_user_by_email(email) if user is None: # Return ok regardless (no user enumeration via reset endpoint). # We still return a (fake) token shape so the response is uniform; # the token is single-use and reset_password validates against DDB. _emit_audit("auth.password_reset_requested", email=email, found=False) return { "status": "ok", "action": "request_password_reset", "reset_token": None, "message": "if the email is registered, a reset token was issued", } reset_token = str(uuid.uuid4()) now = _epoch_now() expires_at = now + RESET_TTL_SECONDS table = _get_dynamodb().Table(PASSWORD_RESETS_TABLE) table.put_item( TableName=PASSWORD_RESETS_TABLE, Item={ "reset_token": reset_token, "user_id": user["user_id"], "expires_at": expires_at, "created_at": _iso8601_now(), }, ) _emit_audit( "auth.password_reset_requested", user_id=user["user_id"], email=email, found=True, ) return { "status": "ok", "action": "request_password_reset", "reset_token": reset_token, "expires_at": expires_at, } def reset_password(payload): """Validate a reset token → set a new password → delete the token. Payload: { reset_token, new_password } On invalid/expired token → ValueError (→ 400). On argon2 unavailable → Argon2UnavailableError (→ 503). """ _require(("reset_token", "new_password"), payload) if not _ARGON2_AVAILABLE: raise Argon2UnavailableError("argon2 unavailable") reset_token = payload["reset_token"] new_password = payload["new_password"] resets = _get_dynamodb().Table(PASSWORD_RESETS_TABLE) resp = resets.get_item( TableName=PASSWORD_RESETS_TABLE, Key={"reset_token": reset_token}, ) item = resp.get("Item") if not item: raise ValueError("invalid or expired reset token") if item.get("expires_at", 0) < _epoch_now(): # Token expired (TTL may not have reaped it yet). raise ValueError("reset token expired") user_id = item["user_id"] new_hash = hash_password(new_password) # fail-closed users = _get_dynamodb().Table(USERS_TABLE) users.update_item( TableName=USERS_TABLE, Key={"user_id": user_id}, UpdateExpression="SET password_hash = :h", ExpressionAttributeValues={":h": new_hash}, ) resets.delete_item( TableName=PASSWORD_RESETS_TABLE, Key={"reset_token": reset_token}, ) _emit_audit("auth.password_reset", user_id=user_id) return { "status": "ok", "action": "reset_password", "user_id": user_id, } # --------------------------------------------------------------------------- # Dispatch (shared by Lambda handler + CLI — REQ-329 dual-use) # --------------------------------------------------------------------------- def dispatch_action(payload, event=None): """Shared business-logic dispatch for the IdP auth Lambda (REQ-329). Both the AWS Lambda handler (``lambda_handler``) and the CLI path (``cli_main`` / ``__main__``) call this so the two paths share a single source of truth for action routing. Args: payload: the decoded action envelope dict, e.g. ``{ action: "sign_up", email, password, owner, roles }``. event: the raw Lambda Function-URL event (unused for identity — the IAM auth is enforced at the Function URL layer; kept for signature symmetry with contract_ingestor). Returns: The action result dict on success. Raises on error — the caller maps exceptions to status codes via :func:`_to_http_response`. """ action = payload.get("action") if action == "sign_up": return sign_up(payload) if action == "sign_in": return sign_in(payload) if action == "create_session": _require(("user_id",), payload) sid = create_session(payload["user_id"]) return {"status": "ok", "action": "create_session", "session_id": sid} if action == "request_password_reset": return request_password_reset(payload) if action == "reset_password": return reset_password(payload) raise ValueError(f"unknown action: {action!r}") def _to_http_response(result_or_error): """Map a dispatch result / exception to a Lambda HTTP response.""" if isinstance(result_or_error, Exception): # Fail-closed: argon2 unavailable → 503 (NO weak hash, NO crash). if isinstance(result_or_error, Argon2UnavailableError): return { "statusCode": 503, "body": json.dumps({"error": "argon2_unavailable"}), } if isinstance(result_or_error, _DuplicateEmailError): return { "statusCode": 409, "body": json.dumps({"error": "email_already_registered"}), } if isinstance(result_or_error, _UnknownUserError): return { "statusCode": 401, "body": json.dumps({"error": "invalid_credentials"}), } if isinstance(result_or_error, ValueError): return { "statusCode": 400, "body": json.dumps({"error": str(result_or_error)}), } return { "statusCode": 500, "body": json.dumps({"error": str(result_or_error)}), } return {"statusCode": 200, "body": json.dumps(result_or_error)} def lambda_handler(event, context): """AWS Lambda handler entry point (thin wrapper, REQ-329 dual-use). Accepts a Function-URL-style event whose ``body`` is a JSON string containing ``{ action, email, password, ... }``. Parses the envelope then delegates to :func:`dispatch_action`. """ # Fail-closed fast-path: if argon2 is unavailable, sign_up / sign_in / # reset_password all raise Argon2UnavailableError which maps to 503. # We do NOT short-circuit here so non-password actions (create_session) # still work when argon2 is down — only the hashing paths fail closed. try: body = event.get("body", "{}") payload = json.loads(body) if isinstance(body, str) else body result = dispatch_action(payload, event=event) return _to_http_response(result) except Exception as e: return _to_http_response(e) # --------------------------------------------------------------------------- # CLI (dual-use, REQ-329 pattern) # --------------------------------------------------------------------------- def cli_main(argv=None): """CLI entry point for the IdP auth Lambda (REQ-329 dual-use). Usage: python3 -m core.lambda.nova_idp_auth --sign-up python3 -m core.lambda.nova_idp_auth --sign-in python3 -m core.lambda.nova_idp_auth --create-session python3 -m core.lambda.nova_idp_auth --request-reset python3 -m core.lambda.nova_idp_auth --reset-password python3 -m core.lambda.nova_idp_auth --dispatch python3 -m core.lambda.nova_idp_auth --dispatch-stdin < """ import sys raw = argv if argv is not None else sys.argv[1:] local_bypass = os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS") if not local_bypass: os.environ["NOVA_LAMBDA_LOCAL_BYPASS"] = "1" try: if "--dispatch-stdin" in raw: payload = json.loads(sys.stdin.read()) elif "--dispatch" in raw: idx = raw.index("--dispatch") path = raw[idx + 1] if idx + 1 < len(raw) else None if not path: print("Usage: --dispatch ", file=sys.stderr) return 2 with open(path) as fh: payload = json.loads(fh.read()) elif "--sign-up" in raw: idx = raw.index("--sign-up") email, password, owner = raw[idx + 1 : idx + 4] roles = ["user"] payload = { "action": "sign_up", "email": email, "password": password, "owner": owner, "roles": roles, } elif "--sign-in" in raw: idx = raw.index("--sign-in") email, password = raw[idx + 1 : idx + 3] payload = {"action": "sign_in", "email": email, "password": password} elif "--create-session" in raw: idx = raw.index("--create-session") user_id = raw[idx + 1] payload = {"action": "create_session", "user_id": user_id} elif "--request-reset" in raw: idx = raw.index("--request-reset") email = raw[idx + 1] payload = {"action": "request_password_reset", "email": email} elif "--reset-password" in raw: idx = raw.index("--reset-password") token, new_password = raw[idx + 1 : idx + 3] payload = { "action": "reset_password", "reset_token": token, "new_password": new_password, } else: print( "Usage: python3 -m core.lambda.nova_idp_auth " "--sign-up | " "--sign-in | " "--dispatch ", file=sys.stderr, ) return 2 result = dispatch_action(payload, event=None) sys.stdout.write(json.dumps(result, indent=2) + "\n") return 0 except Argon2UnavailableError as e: sys.stderr.write(f"error: {e}\n") return 3 # 503-class except ValueError as e: sys.stderr.write(f"error: {e}\n") return 1 except _DuplicateEmailError as e: sys.stderr.write(f"error: {e}\n") return 9 # 409-class except _UnknownUserError as e: sys.stderr.write(f"error: {e}\n") return 1 # 401-class except Exception as e: # pragma: no cover - defensive top-level guard sys.stderr.write(f"internal error: {e}\n") return 2 finally: if not local_bypass: os.environ.pop("NOVA_LAMBDA_LOCAL_BYPASS", None) if __name__ == "__main__": # pragma: no cover - CLI entry import sys sys.exit(cli_main())