chore: merge executor worktree (worktree-agent-a9c4907ff53a1a111)

# Conflicts:
#	.env.example
#	README.md
#	backend/config.py
#	backend/main.py
#	backend/tests/test_auth_es256.py
This commit is contained in:
curo1305
2026-06-06 18:07:31 +02:00
5 changed files with 254 additions and 131 deletions
+17 -5
View File
@@ -59,6 +59,7 @@ class LoginRequest(BaseModel):
password: str password: str
totp_code: Optional[str] = None totp_code: Optional[str] = None
backup_code: Optional[str] = None backup_code: Optional[str] = None
remember_me: bool = False
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
@@ -68,8 +69,19 @@ class ChangePasswordRequest(BaseModel):
# ── Helper: set httpOnly refresh cookie ────────────────────────────────────── # ── Helper: set httpOnly refresh cookie ──────────────────────────────────────
def _set_refresh_cookie(response: Response, raw_token: str) -> None: def _set_refresh_cookie(
"""Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).""" response: Response, raw_token: str, remember_me: bool = False
) -> None:
"""Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).
remember_me=False (default): Max-Age = refresh_token_expire_hours * 3600 (16h, D-11, RM-03)
remember_me=True: Max-Age = refresh_token_expire_days * 86400 (30d, D-11, RM-03)
"""
max_age = (
settings.refresh_token_expire_days * 86400
if remember_me
else settings.refresh_token_expire_hours * 3600
)
response.set_cookie( response.set_cookie(
key="refresh_token", key="refresh_token",
value=raw_token, value=raw_token,
@@ -77,7 +89,7 @@ def _set_refresh_cookie(response: Response, raw_token: str) -> None:
secure=True, secure=True,
samesite="strict", samesite="strict",
path="/api/auth/refresh", path="/api/auth/refresh",
max_age=settings.refresh_token_expire_days * 86400, max_age=max_age,
) )
@@ -276,8 +288,8 @@ async def login(
# Issue tokens # Issue tokens
access_token = auth_service.create_access_token(str(user.id), user.role) access_token = auth_service.create_access_token(str(user.id), user.role)
raw_refresh = await auth_service.create_refresh_token(session, user.id) raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me)
_set_refresh_cookie(response, raw_refresh) _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)
# D-13: login success event # D-13: login success event
await write_audit_log( await write_audit_log(
+12 -2
View File
@@ -157,20 +157,30 @@ def decode_password_reset_token(token: str) -> str:
# ── Refresh token lifecycle ───────────────────────────────────────────────────── # ── Refresh token lifecycle ─────────────────────────────────────────────────────
async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str: async def create_refresh_token(
session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False
) -> str:
"""Insert a new RefreshToken row and return the raw (unhashed) token string. """Insert a new RefreshToken row and return the raw (unhashed) token string.
The raw token is returned to the caller and set as an httpOnly cookie. The raw token is returned to the caller and set as an httpOnly cookie.
Only the SHA-256 hash is stored in the database. Only the SHA-256 hash is stored in the database.
remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10)
remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11)
""" """
raw = secrets.token_urlsafe(32) raw = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw.encode()).hexdigest() token_hash = hashlib.sha256(raw.encode()).hexdigest()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ttl = (
timedelta(days=settings.refresh_token_expire_days)
if remember_me
else timedelta(hours=settings.refresh_token_expire_hours)
)
row = RefreshToken( row = RefreshToken(
id=uuid.uuid4(), id=uuid.uuid4(),
user_id=user_id, user_id=user_id,
token_hash=token_hash, token_hash=token_hash,
expires_at=now + timedelta(days=settings.refresh_token_expire_days), expires_at=now + ttl,
revoked=False, revoked=False,
) )
session.add(row) session.add(row)
+209 -123
View File
@@ -2,6 +2,8 @@
TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation, TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation,
and remember_me session TTL — all stubs xfail strict=True until promoted. and remember_me session TTL — all stubs xfail strict=True until promoted.
""" """
from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json import json
@@ -11,56 +13,61 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
# ── ES256 key fixture ────────────────────────────────────────────────────────
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def es256_keys(monkeypatch): def es256_keys(monkeypatch):
"""Patch settings with a freshly generated P-256 key pair for each test.""" """Generate a throw-away P-256 keypair and monkeypatch it into settings.
k = ec.generate_private_key(ec.SECP256R1())
priv = base64.b64encode( raising=False: settings fields do not exist until Plan 02 adds them;
k.private_bytes( this fixture must not error before that.
serialization.Encoding.PEM, """
serialization.PrivateFormat.PKCS8, from cryptography.hazmat.primitives.asymmetric import ec
serialization.NoEncryption(), from cryptography.hazmat.primitives import serialization
)
).decode() private_key = ec.generate_private_key(ec.SECP256R1())
pub = base64.b64encode( private_pem = private_key.private_bytes(
k.public_key().public_bytes( encoding=serialization.Encoding.PEM,
serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8,
serialization.PublicFormat.SubjectPublicKeyInfo, encryption_algorithm=serialization.NoEncryption(),
) )
).decode() public_pem = private_key.public_key().public_bytes(
monkeypatch.setattr("config.settings.jwt_private_key", priv, raising=False) encoding=serialization.Encoding.PEM,
monkeypatch.setattr("config.settings.jwt_public_key", pub, raising=False) format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
private_b64 = base64.b64encode(private_pem).decode()
public_b64 = base64.b64encode(public_pem).decode()
import config
monkeypatch.setattr(config.settings, "jwt_private_key", private_b64, raising=False)
monkeypatch.setattr(config.settings, "jwt_public_key", public_b64, raising=False)
# ── ES256 algorithm tests ───────────────────────────────────────────────────── # ── ES256-01: access token algorithm ─────────────────────────────────────────
def test_access_token_uses_es256(): def test_access_token_uses_es256():
"""ES256-01: access token header must declare alg=ES256."""
from services.auth import create_access_token from services.auth import create_access_token
token = create_access_token("u1", "user") token = create_access_token("u1", "user")
# Decode the header (first segment of the JWT) # Decode header (first segment of JWT)
header_b64 = token.split(".")[0] segment = token.split(".")[0]
# Add padding to make it valid base64 # Add padding
padding = "=" * (4 - len(header_b64) % 4) segment += "=" * (4 - len(segment) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) header = json.loads(base64.urlsafe_b64decode(segment))
assert header["alg"] == "ES256" assert header["alg"] == "ES256"
# ── ES256-02: HS256 token rejected ───────────────────────────────────────────
def test_hs256_token_rejected(): def test_hs256_token_rejected():
"""ES256-02: an HS256 token must raise ValueError when decoded."""
import jwt as _jwt import jwt as _jwt
from services.auth import decode_access_token from services.auth import decode_access_token
hs256_token = _jwt.encode( hs256_token = _jwt.encode(
{ {"sub": "u1", "typ": "access", "exp": int(time.time()) + 60, "iat": int(time.time())},
"sub": "u1",
"typ": "access",
"exp": int(time.time()) + 60,
"iat": int(time.time()),
},
"any-hs256-secret", "any-hs256-secret",
algorithm="HS256", algorithm="HS256",
) )
@@ -68,113 +75,111 @@ def test_hs256_token_rejected():
decode_access_token(hs256_token) decode_access_token(hs256_token)
# ── ES256-03: password-reset token algorithm ──────────────────────────────────
def test_reset_token_uses_es256(): def test_reset_token_uses_es256():
"""ES256-03: password-reset token header must declare alg=ES256 and round-trips."""
from services.auth import create_password_reset_token, decode_password_reset_token from services.auth import create_password_reset_token, decode_password_reset_token
token = create_password_reset_token("u1") token = create_password_reset_token("u1")
header_b64 = token.split(".")[0] segment = token.split(".")[0]
padding = "=" * (4 - len(header_b64) % 4) segment += "=" * (4 - len(segment) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) header = json.loads(base64.urlsafe_b64decode(segment))
assert header["alg"] == "ES256" assert header["alg"] == "ES256"
assert decode_password_reset_token(token) == "u1" assert decode_password_reset_token(token) == "u1"
# ── Startup token rotation tests ────────────────────────────────────────────── # ── ES256-04: startup rotation revokes tokens ─────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_rotation_revokes_tokens(db_session): async def test_startup_rotation_revokes_tokens(db_session):
"""ES256-04: on first run, all active refresh tokens are bulk-revoked and jwt_algorithm row is upserted.""" from datetime import datetime, timezone, timedelta
import secrets as _secrets import hashlib
from main import _rotate_tokens_on_algorithm_change import secrets
from db.models import RefreshToken, SystemSettings, User import uuid as _uuid
from sqlalchemy import select from sqlalchemy import select
from db.models import RefreshToken, SystemSettings, User, Quota
from main import _rotate_tokens_on_algorithm_change
from services.auth import hash_password
# Create a minimal user row (required by RefreshToken FK) # Set up: create a user + two RefreshToken rows with revoked=False
user_id = uuid.uuid4() user_id = _uuid.uuid4()
user = User( user = User(
id=user_id, id=user_id,
handle=f"testrotate_{user_id.hex[:8]}", handle=f"rottest_{user_id.hex[:8]}",
email=f"testrotate_{user_id.hex[:8]}@example.com", email=f"rottest_{user_id.hex[:8]}@example.com",
password_hash="fakehash", password_hash=hash_password("Testpassword123!"),
role="user", role="user",
is_active=True, is_active=True,
password_must_change=False, password_must_change=False,
) )
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user) db_session.add(user)
await db_session.flush() db_session.add(quota)
# Insert two active refresh tokens
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
tok1_id = uuid.uuid4() rt1_id = _uuid.uuid4()
tok2_id = uuid.uuid4() rt2_id = _uuid.uuid4()
tok1 = RefreshToken( rt1 = RefreshToken(
id=tok1_id, id=rt1_id,
user_id=user_id, user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1), expires_at=now + timedelta(days=1),
revoked=False, revoked=False,
) )
tok2 = RefreshToken( rt2 = RefreshToken(
id=tok2_id, id=rt2_id,
user_id=user_id, user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1), expires_at=now + timedelta(days=1),
revoked=False, revoked=False,
) )
db_session.add(tok1) db_session.add(rt1)
db_session.add(tok2) db_session.add(rt2)
await db_session.flush() await db_session.flush()
# No jwt_algorithm row in system_settings
# Act: no jwt_algorithm row exists yet # Act
await _rotate_tokens_on_algorithm_change(db_session) await _rotate_tokens_on_algorithm_change(db_session)
# expire_all() required: raw SQL UPDATE bypasses ORM identity map; expire_on_commit=False # After the helper's commit, expire the identity map so we read fresh DB state
# (set in conftest.py) means SQLAlchemy won't auto-reload stale objects after commit.
db_session.expire_all() db_session.expire_all()
# Assert: both tokens are now revoked # Assert: both tokens revoked
result = await db_session.execute( result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id.in_([tok1_id, tok2_id])) select(RefreshToken).where(RefreshToken.id.in_([rt1_id, rt2_id]))
) )
rows = result.scalars().all() rows = result.scalars().all()
assert len(rows) == 2 assert len(rows) == 2
assert all(r.revoked is True for r in rows) assert all(r.revoked for r in rows)
# Assert: jwt_algorithm row was created correctly # Assert: jwt_algorithm marker row created
ss_result = await db_session.execute( result2 = await db_session.execute(
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
) )
ss_row = ss_result.scalar_one_or_none() marker = result2.scalar_one_or_none()
assert ss_row is not None assert marker is not None
assert ss_row.model_name == "ES256" assert marker.model_name == "ES256"
assert ss_row.is_active is False assert not marker.is_active
assert ss_row.context_chars == 0 assert marker.context_chars == 0
# ── ES256-05: startup rotation is idempotent ─────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_rotation_idempotent(db_session): async def test_startup_rotation_idempotent(db_session):
"""ES256-05: when jwt_algorithm row already has model_name=ES256, no tokens are revoked.""" import uuid as _uuid
import secrets as _secrets
from main import _rotate_tokens_on_algorithm_change
from db.models import RefreshToken, SystemSettings, User
from sqlalchemy import select, func from sqlalchemy import select, func
from db.models import RefreshToken, SystemSettings, User, Quota
from main import _rotate_tokens_on_algorithm_change
from services.auth import hash_password
from datetime import datetime, timezone, timedelta
import hashlib
import secrets
# Create a user # Set up: pre-existing jwt_algorithm marker row with model_name='ES256'
user_id = uuid.uuid4() marker_id = _uuid.uuid4()
user = User( marker = SystemSettings(
id=user_id, id=marker_id,
handle=f"testidempotent_{user_id.hex[:8]}",
email=f"testidempotent_{user_id.hex[:8]}@example.com",
password_hash="fakehash",
role="user",
is_active=True,
password_must_change=False,
)
db_session.add(user)
await db_session.flush()
# Seed the jwt_algorithm row as already migrated
existing_marker = SystemSettings(
id=uuid.uuid4(),
provider_id="jwt_algorithm", provider_id="jwt_algorithm",
model_name="ES256", model_name="ES256",
context_chars=0, context_chars=0,
@@ -182,66 +187,147 @@ async def test_startup_rotation_idempotent(db_session):
api_key_enc=None, api_key_enc=None,
base_url=None, base_url=None,
) )
db_session.add(existing_marker) db_session.add(marker)
# Insert one fresh active token # Create a user + one fresh RefreshToken with revoked=False
user_id = _uuid.uuid4()
user = User(
id=user_id,
handle=f"idem_{user_id.hex[:8]}",
email=f"idem_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
tok_id = uuid.uuid4() rt_id = _uuid.uuid4()
tok = RefreshToken( rt = RefreshToken(
id=tok_id, id=rt_id,
user_id=user_id, user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1), expires_at=now + timedelta(days=1),
revoked=False, revoked=False,
) )
db_session.add(tok) db_session.add(rt)
await db_session.flush() await db_session.flush()
# Act # Act: run rotation — should be no-op because model_name is already 'ES256'
await _rotate_tokens_on_algorithm_change(db_session) await _rotate_tokens_on_algorithm_change(db_session)
# expire_all() to clear identity map cache (same reason as test_startup_rotation_revokes_tokens)
db_session.expire_all()
# Assert: the fresh token was NOT revoked (bulk update did not fire) # Assert: RefreshToken still not revoked
result = await db_session.execute( result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id == tok_id) select(RefreshToken).where(RefreshToken.id == rt_id)
) )
fresh_tok = result.scalar_one_or_none() token_row = result.scalar_one_or_none()
assert fresh_tok is not None assert token_row is not None
assert fresh_tok.revoked is False assert not token_row.revoked
# Assert: still exactly one jwt_algorithm row (no duplicate upsert) # Assert: exactly one jwt_algorithm row (no duplicates)
count_result = await db_session.execute( result2 = await db_session.execute(
select(func.count()).where(SystemSettings.provider_id == "jwt_algorithm") select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
) )
count = count_result.scalar_one() markers = result2.scalars().all()
assert count == 1 assert len(markers) == 1
# ── Remember-me TTL stubs ───────────────────────────────────────────────────── # ── RM helpers ───────────────────────────────────────────────────────────────
async def _do_login(async_client, auth_user, remember_me: bool = False) -> dict:
"""POST /api/auth/login for the auth_user and return the response."""
from tests.test_auth_api import FakeRedis
from main import app
app.state.redis = FakeRedis()
resp = await async_client.post(
"/api/auth/login",
json={
"email": auth_user["user"].email,
"password": "Testpassword123!",
"remember_me": remember_me,
},
)
return resp
# ── RM-01: default TTL is 16 hours ───────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-01: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_default_ttl_16_hours(async_client, db_session, auth_user): async def test_default_ttl_16_hours(async_client, db_session, auth_user):
pytest.xfail("not yet implemented") from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from db.models import RefreshToken
resp = await _do_login(async_client, auth_user, remember_me=False)
assert resp.status_code == 200
uid = auth_user["user"].id
result = await db_session.execute(
select(RefreshToken)
.where(RefreshToken.user_id == uid)
.order_by(RefreshToken.id.desc())
)
row = result.scalars().first()
assert row is not None
now = datetime.now(timezone.utc)
delta = row.expires_at.replace(tzinfo=timezone.utc) - now
assert timedelta(hours=15, minutes=30) < delta < timedelta(hours=16, minutes=30)
# ── RM-02: remember_me TTL is 30 days ────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-02: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remember_me_ttl_30_days(async_client, db_session, auth_user): async def test_remember_me_ttl_30_days(async_client, db_session, auth_user):
pytest.xfail("not yet implemented") from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from db.models import RefreshToken
resp = await _do_login(async_client, auth_user, remember_me=True)
assert resp.status_code == 200
uid = auth_user["user"].id
result = await db_session.execute(
select(RefreshToken)
.where(RefreshToken.user_id == uid)
.order_by(RefreshToken.id.desc())
)
row = result.scalars().first()
assert row is not None
now = datetime.now(timezone.utc)
delta = row.expires_at.replace(tzinfo=timezone.utc) - now
assert timedelta(days=29, hours=23) < delta < timedelta(days=30, hours=1)
# ── RM-03: cookie Max-Age values ─────────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-03: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remember_me_cookie_max_age(async_client, auth_user): async def test_remember_me_cookie_max_age(async_client, auth_user):
pytest.xfail("not yet implemented") # Default (no remember_me): Max-Age = 16 * 3600 = 57600
resp_short = await _do_login(async_client, auth_user, remember_me=False)
assert resp_short.status_code == 200
# Parse raw Set-Cookie header for Max-Age
set_cookie_short = resp_short.headers.get("set-cookie", "")
assert "Max-Age=57600" in set_cookie_short
# With remember_me=True: Max-Age = 30 * 86400 = 2592000
resp_long = await _do_login(async_client, auth_user, remember_me=True)
assert resp_long.status_code == 200
set_cookie_long = resp_long.headers.get("set-cookie", "")
assert "Max-Age=2592000" in set_cookie_long
# ── Config field tests ──────────────────────────────────────────────────────── # ── CFG-01 satellite: settings has jwt key fields ────────────────────────────
def test_settings_has_jwt_keys(): def test_settings_has_jwt_keys():
"""CFG-01: Settings must expose jwt_private_key, jwt_public_key, and refresh_token_expire_hours=16."""
from config import settings from config import settings
assert hasattr(settings, "jwt_private_key") assert hasattr(settings, "jwt_private_key")
assert hasattr(settings, "jwt_public_key") assert hasattr(settings, "jwt_public_key")
+1
View File
@@ -66,6 +66,7 @@ export const useAuthStore = defineStore('auth', () => {
password, password,
totp_code: options.totpCode ?? null, totp_code: options.totpCode ?? null,
backup_code: options.backupCode ?? null, backup_code: options.backupCode ?? null,
remember_me: options.rememberMe ?? false,
}) })
if (data.requires_totp) { if (data.requires_totp) {
+15 -1
View File
@@ -27,6 +27,17 @@
/> />
</div> </div>
<!-- Stay signed in checkbox (D-12) -->
<div class="flex items-center gap-2">
<input
v-model="rememberMe"
id="remember-me"
type="checkbox"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<label for="remember-me" class="text-sm text-gray-600">Stay signed in for 30 days</label>
</div>
<!-- Form-level error --> <!-- Form-level error -->
<div <div
v-if="error" v-if="error"
@@ -191,6 +202,7 @@ const totpInput = ref('')
const backupCodeInput = ref('') const backupCodeInput = ref('')
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const rememberMe = ref(false)
// Step: 'password' | 'totp' | 'backup' // Step: 'password' | 'totp' | 'backup'
const step = ref('password') const step = ref('password')
@@ -225,7 +237,7 @@ async function submitPassword() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const result = await authStore.login(email.value, password.value) const result = await authStore.login(email.value, password.value, { rememberMe: rememberMe.value })
await handleLoginResult(result) await handleLoginResult(result)
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
@@ -240,6 +252,7 @@ async function submitTotp() {
try { try {
const result = await authStore.login(email.value, password.value, { const result = await authStore.login(email.value, password.value, {
totpCode: totpInput.value, totpCode: totpInput.value,
rememberMe: rememberMe.value,
}) })
await handleLoginResult(result) await handleLoginResult(result)
} catch (e) { } catch (e) {
@@ -255,6 +268,7 @@ async function submitBackupCode() {
try { try {
const result = await authStore.login(email.value, password.value, { const result = await authStore.login(email.value, password.value, {
backupCode: backupCodeInput.value, backupCode: backupCodeInput.value,
rememberMe: rememberMe.value,
}) })
await handleLoginResult(result) await handleLoginResult(result)
} catch (e) { } catch (e) {