From 8550ede8100676d8156322143aac9ce5e577e705 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 19 Aug 2026 22:56:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(P03):=20Argon2id=20hashing=20=E2=80=94=20f?= =?UTF-8?q?ail-closed,=20t=3D3=20m=3D65536=20p=3D1=20(REQ-334,=20D-228,=20?= =?UTF-8?q?C-7.2,=20security-engineer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full nova-idp-auth Lambda handler is included in this commit (sign_up, sign_in, create_session, request_password_reset, reset_password) since the hashing module and handler share one file. The Argon2id hashing + fail-closed logic is the security-engineer territory; the Lambda plumbing is backend-engineer. ---ci--- project: acdl phase: 3 milestone: v1.28 status: execute persona: security-engineer --- --- core/lambda/nova_idp_auth.py | 613 +++++++++++++++++++++++++++++++++++ 1 file changed, 613 insertions(+) create mode 100644 core/lambda/nova_idp_auth.py diff --git a/core/lambda/nova_idp_auth.py b/core/lambda/nova_idp_auth.py new file mode 100644 index 0000000..b12546f --- /dev/null +++ b/core/lambda/nova_idp_auth.py @@ -0,0 +1,613 @@ +"""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()) \ No newline at end of file