Add loopback HTTPS smoke test

This commit is contained in:
Hamza Ayed
2026-10-03 14:02:11 +03:00
parent ceae822302
commit 052e600e32
3 changed files with 155 additions and 1 deletions
+1
View File
@@ -164,6 +164,7 @@ python scripts/eval_knowledge_retrieval.py --dataset evals/knowledge_retrieval_p
- لكل سجل محادثة `user_id`، وتُفلتر القراءة والتعديل والحذف بهوية جلسة Bearer موثقة؛ لم يعد `X-User-ID` يمنح أي صلاحية. في الوضع المحلي تطلب الواجهة تلقائيًا جلسة للمستخدم المحلي، ولا يصدرها الخادم إلا لاتصال loopback. - لكل سجل محادثة `user_id`، وتُفلتر القراءة والتعديل والحذف بهوية جلسة Bearer موثقة؛ لم يعد `X-User-ID` يمنح أي صلاحية. في الوضع المحلي تطلب الواجهة تلقائيًا جلسة للمستخدم المحلي، ولا يصدرها الخادم إلا لاتصال loopback.
- يوفّر API الآن `POST /v1/auth/register` و`POST /v1/auth/login` و`GET /v1/auth/me` و`POST /v1/auth/logout`. كلمات المرور تُخزن بتجزئة PBKDF2 مع salt؛ رمز الجلسة العشوائي يُخزن كـSHA-256 وينتهي بعد 7 أيام ويمكن إلغاؤه. أضاف Flutter واجهة تسجيل ودخول وخروج، ويحفظ رمز الحساب عبر `flutter_secure_storage` في تطبيقات الأنظمة الأصلية. على الويب يبقى الرمز في الذاكرة فقط ولا يُحفظ بعد إعادة تحميل الصفحة أو إغلاق التبويب؛ بذلك يحتاج المستخدم لتسجيل الدخول مجددًا. هذا يتجنب التخزين الدائم في JavaScript-accessible browser storage، لكنه لا يمنع سرقة الجلسة أثناء تشغيل صفحة مخترقة؛ يحتاج النشر العام مراجعة حماية XSS وHTTPS. - يوفّر API الآن `POST /v1/auth/register` و`POST /v1/auth/login` و`GET /v1/auth/me` و`POST /v1/auth/logout`. كلمات المرور تُخزن بتجزئة PBKDF2 مع salt؛ رمز الجلسة العشوائي يُخزن كـSHA-256 وينتهي بعد 7 أيام ويمكن إلغاؤه. أضاف Flutter واجهة تسجيل ودخول وخروج، ويحفظ رمز الحساب عبر `flutter_secure_storage` في تطبيقات الأنظمة الأصلية. على الويب يبقى الرمز في الذاكرة فقط ولا يُحفظ بعد إعادة تحميل الصفحة أو إغلاق التبويب؛ بذلك يحتاج المستخدم لتسجيل الدخول مجددًا. هذا يتجنب التخزين الدائم في JavaScript-accessible browser storage، لكنه لا يمنع سرقة الجلسة أثناء تشغيل صفحة مخترقة؛ يحتاج النشر العام مراجعة حماية XSS وHTTPS.
- استعادة كلمة المرور عبر `POST /v1/auth/password-reset/request` ثم `POST /v1/auth/password-reset/complete`. الإرسال معطل حتى تضبط على خادم FastAPI: `SOVEREIGNAI_SMTP_HOST`, `SOVEREIGNAI_SMTP_PORT` (587 افتراضيًا أو 465 مع SSL), `SOVEREIGNAI_SMTP_SECURITY` (`starttls` افتراضيًا أو `ssl`), `SOVEREIGNAI_SMTP_FROM`, وبيانات الدخول الاختيارية `SOVEREIGNAI_SMTP_USERNAME`/`SOVEREIGNAI_SMTP_PASSWORD`. لا تحفظ كلمة مرور SMTP في Flutter أو Git. رمز الاستعادة عمره 30 دقيقة، بصمته فقط محفوظة، ويُستخدم مرة واحدة؛ تغيير كلمة المرور يلغي جلسات الحساب السابقة. رد طلب الاستعادة عام، والميزة تعيد 503 إن لم تُهيأ خدمة SMTP. - استعادة كلمة المرور عبر `POST /v1/auth/password-reset/request` ثم `POST /v1/auth/password-reset/complete`. الإرسال معطل حتى تضبط على خادم FastAPI: `SOVEREIGNAI_SMTP_HOST`, `SOVEREIGNAI_SMTP_PORT` (587 افتراضيًا أو 465 مع SSL), `SOVEREIGNAI_SMTP_SECURITY` (`starttls` افتراضيًا أو `ssl`), `SOVEREIGNAI_SMTP_FROM`, وبيانات الدخول الاختيارية `SOVEREIGNAI_SMTP_USERNAME`/`SOVEREIGNAI_SMTP_PASSWORD`. لا تحفظ كلمة مرور SMTP في Flutter أو Git. رمز الاستعادة عمره 30 دقيقة، بصمته فقط محفوظة، ويُستخدم مرة واحدة؛ تغيير كلمة المرور يلغي جلسات الحساب السابقة. رد طلب الاستعادة عام، والميزة تعيد 503 إن لم تُهيأ خدمة SMTP.
- للتحقق من TLS محليًا فقط، شغّل `.venv/Scripts/python.exe scripts/local_tls_smoke.py` على Windows. ينشئ شهادة مؤقتة، يربط FastAPI على loopback، يختبر الشهادة الموثوقة صراحة ويرفض الافتراضية غير الموثوقة، ثم ينظف الشهادة والخادم. هذا smoke test؛ لا يثبت شهادة دائمة أو ثقة Flutter/المتصفح أو جاهزية الوصول الشبكي.
- بناء Windows يتطلب مكوّن C++ ATL ضمن Visual Studio Build Tools بسبب إضافة `flutter_secure_storage_windows`؛ ATL مثبت الآن ونجح بناء Debug على هذا الجهاز. على Android الحد الأدنى صار API 23. - بناء Windows يتطلب مكوّن C++ ATL ضمن Visual Studio Build Tools بسبب إضافة `flutter_secure_storage_windows`؛ ATL مثبت الآن ونجح بناء Debug على هذا الجهاز. على Android الحد الأدنى صار API 23.
- كل عمليات `/v1/*` الخاصة تتطلب الآن Bearer session؛ الاستثناءات العامة هي الصحة وقائمة النماذج وتسجيل/دخول الحساب وإنشاء الجلسة المحلية. عميل Flutter يرسل الجلسة للمحادثة والوكيل والملفات والمعرفة والبحث والصوت. ملكية سجل تدقيق الوكيل وفهرس المعرفة ومقترحات تعديل الملفات مرتبطة بمعرّف الحساب. - كل عمليات `/v1/*` الخاصة تتطلب الآن Bearer session؛ الاستثناءات العامة هي الصحة وقائمة النماذج وتسجيل/دخول الحساب وإنشاء الجلسة المحلية. عميل Flutter يرسل الجلسة للمحادثة والوكيل والملفات والمعرفة والبحث والصوت. ملكية سجل تدقيق الوكيل وفهرس المعرفة ومقترحات تعديل الملفات مرتبطة بمعرّف الحساب.
- أضيف حد أولي لمحاولات الدخول وإنشاء الحسابات وطلبات استعادة كلمة المرور في SQLite، لكنه لم يخضع بعد لاختبار ضغط/خلف reverse proxy. يخصص `SOVEREIGNAI_USER_WORKSPACES` مجلدات منفصلة للحسابات ضمن جذور `SOVEREIGNAI_ALLOWED_WORKSPACES`، لكن عملية FastAPI ما زالت ترث صلاحيات نظام التشغيل. أبقِ الخدمة على `127.0.0.1` إلى أن يكتمل العزل على مستوى العملية ويُختبر TLS. - أضيف حد أولي لمحاولات الدخول وإنشاء الحسابات وطلبات استعادة كلمة المرور في SQLite، لكنه لم يخضع بعد لاختبار ضغط/خلف reverse proxy. يخصص `SOVEREIGNAI_USER_WORKSPACES` مجلدات منفصلة للحسابات ضمن جذور `SOVEREIGNAI_ALLOWED_WORKSPACES`، لكن عملية FastAPI ما زالت ترث صلاحيات نظام التشغيل. أبقِ الخدمة على `127.0.0.1` إلى أن يكتمل العزل على مستوى العملية ويُختبر TLS.
+2 -1
View File
@@ -74,7 +74,8 @@
- [x] حفظ رمز الحساب عبر `flutter_secure_storage` في مخزن النظام على المنصات الأصلية المدعومة، واستعادة `/v1/auth/me` عند بدء التطبيق؛ Android مضبوط على API 23 كحد أدنى. الويب لا يحفظ رمز الجلسة دائمًا، ويحتفظ به في الذاكرة فقط حتى إعادة التحميل أو إغلاق التبويب. - [x] حفظ رمز الحساب عبر `flutter_secure_storage` في مخزن النظام على المنصات الأصلية المدعومة، واستعادة `/v1/auth/me` عند بدء التطبيق؛ Android مضبوط على API 23 كحد أدنى. الويب لا يحفظ رمز الجلسة دائمًا، ويحتفظ به في الذاكرة فقط حتى إعادة التحميل أو إغلاق التبويب.
- [x] اختبار مخزن جلسة الويب المؤقت (2/2) وبناء Flutter Web ناجح (2026-10-03). رسالة الدخول توضح انتهاء الجلسة عند إعادة تحميل الصفحة. - [x] اختبار مخزن جلسة الويب المؤقت (2/2) وبناء Flutter Web ناجح (2026-10-03). رسالة الدخول توضح انتهاء الجلسة عند إعادة تحميل الصفحة.
- [x] إضافة استعادة كلمة المرور عبر SMTP: رمز عشوائي 30 دقيقة بصمة SHA-256، لمرة واحدة، تحديد طلبات حسب البريد والعميل، رسالة API عامة، وتسجيل خروج كل الجلسات القديمة بعد تغيير كلمة المرور. مسارات Flutter للطلب وإدخال الرمز وتغيير كلمة المرور مضافة؛ 12 اختبار مصادقة نجحت، و`flutter analyze` نظيف (2026-10-03). يتطلب الإرسال إعداد متغيرات SMTP الموثقة في README. - [x] إضافة استعادة كلمة المرور عبر SMTP: رمز عشوائي 30 دقيقة بصمة SHA-256، لمرة واحدة، تحديد طلبات حسب البريد والعميل، رسالة API عامة، وتسجيل خروج كل الجلسات القديمة بعد تغيير كلمة المرور. مسارات Flutter للطلب وإدخال الرمز وتغيير كلمة المرور مضافة؛ 12 اختبار مصادقة نجحت، و`flutter analyze` نظيف (2026-10-03). يتطلب الإرسال إعداد متغيرات SMTP الموثقة في README.
- [ ] تشغيل نسخة FastAPI محلية عبر TLS والتحقق من الشهادة والاتصال من Flutter قبل السماح بالوصول الشبكي. - [x] اختبار FastAPI محليًا عبر TLS: `scripts/local_tls_smoke.py` ولّد شهادة اختبار مؤقتة، شغّل الخادم على `127.0.0.1`، تحقق من شهادة TLS صراحة وأعاد `/openapi.json` الحالة 200، ورفض شهادة غير موثوقة؛ أُغلق الخادم وحُذفت ملفات الشهادة بعد الاختبار (2026-10-03).
- [ ] التحقق من الثقة بالشهادة من Flutter/المتصفح، ثم تجربة TLS بإعداد نشر دائم قبل أي وصول شبكي. شهادة smoke ذاتية التوقيع مؤقتة وليست شهادة نشر.
- [x] حد أولي دائم لتخمين كلمات المرور وإنشاء الحسابات في SQLite مع نافذة انتهاء و`Retry-After`؛ اختبرته مجموعة Python كاملة، وتبقى مراجعة الحدود تحت reverse proxy موثوق قبل أي نشر خارجي. - [x] حد أولي دائم لتخمين كلمات المرور وإنشاء الحسابات في SQLite مع نافذة انتهاء و`Retry-After`؛ اختبرته مجموعة Python كاملة، وتبقى مراجعة الحدود تحت reverse proxy موثوق قبل أي نشر خارجي.
- [x] تعطيل حفظ رمز الجلسة الدائم في الويب؛ استخدام مخزن ذاكرة مؤقت وإظهار ذلك للمستخدم. - [x] تعطيل حفظ رمز الجلسة الدائم في الويب؛ استخدام مخزن ذاكرة مؤقت وإظهار ذلك للمستخدم.
- [ ] التحقق من متطلبات Linux keyring وتشغيل تخزين الجلسة الأصلي، وبناء macOS وLinux على منصات فعلية. - [ ] التحقق من متطلبات Linux keyring وتشغيل تخزين الجلسة الأصلي، وبناء macOS وLinux على منصات فعلية.
@@ -0,0 +1,152 @@
"""Launch FastAPI on loopback with a disposable certificate and verify HTTPS."""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parents[1]
def find_openssl() -> str:
found = shutil.which("openssl")
if found:
return found
candidates = (
Path(r"C:\Program Files\Git\usr\bin\openssl.exe"),
Path(r"C:\Program Files\Git\mingw64\bin\openssl.exe"),
)
for candidate in candidates:
if candidate.is_file():
return str(candidate)
raise RuntimeError("OpenSSL is required for this local TLS smoke test")
def main() -> int:
openssl = find_openssl()
runtime_root = ROOT / ".test-runtime"
runtime_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="sovereignai-tls-", dir=runtime_root) as temp:
temp_dir = Path(temp)
certificate = temp_dir / "localhost-cert.pem"
private_key = temp_dir / "localhost-key.pem"
subprocess.run(
[
openssl,
"req",
"-x509",
"-newkey",
"rsa:2048",
"-sha256",
"-nodes",
"-days",
"1",
"-keyout",
str(private_key),
"-out",
str(certificate),
"-subj",
"/CN=SovereignAI local TLS smoke",
"-addext",
"subjectAltName=DNS:localhost,IP:127.0.0.1",
"-addext",
"basicConstraints=critical,CA:TRUE",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
environment = os.environ.copy()
environment["SOVEREIGNAI_DATA_DIR"] = str(temp_dir / "data")
environment["SOVEREIGNAI_ALLOWED_WORKSPACES"] = str(ROOT)
environment["SOVEREIGNAI_WORKSPACE"] = str(ROOT)
environment["PYTHONPATH"] = str(ROOT)
server = subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
"app.main:app",
"--host",
"127.0.0.1",
"--port",
str(port),
"--ssl-certfile",
str(certificate),
"--ssl-keyfile",
str(private_key),
"--no-access-log",
],
cwd=ROOT,
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
try:
url = f"https://127.0.0.1:{port}/openapi.json"
response: httpx.Response | None = None
last_error: Exception | None = None
deadline = time.monotonic() + 25
while time.monotonic() < deadline:
if server.poll() is not None:
break
try:
response = httpx.get(
url, verify=str(certificate), timeout=2, trust_env=False
)
break
except httpx.HTTPError as exc:
last_error = exc
time.sleep(0.2)
if response is None:
server.terminate()
server_output, _ = server.communicate(timeout=5)
raise RuntimeError(
f"HTTPS server failed to become ready: {last_error};\n"
f"{server_output.decode('utf-8', errors='replace')}"
)
if response.status_code != 200:
raise RuntimeError(f"HTTPS OpenAPI request returned {response.status_code}")
spec = response.json()
paths = spec.get("paths", {})
if "/v1/auth/password-reset/request" not in paths:
raise RuntimeError("Expected password reset route is missing from OpenAPI")
untrusted_certificate_rejected = False
try:
httpx.get(url, verify=True, timeout=2, trust_env=False)
except httpx.ConnectError:
untrusted_certificate_rejected = True
if not untrusted_certificate_rejected:
raise RuntimeError("The client unexpectedly trusted the disposable certificate")
print("tls_verified_with_explicit_local_certificate=true")
print(f"https_openapi_status={response.status_code}")
print("server_bind_host=127.0.0.1")
print("untrusted_certificate_rejected=true")
print("password_reset_route_published=true")
finally:
if server.poll() is None:
server.terminate()
try:
server.wait(timeout=5)
except subprocess.TimeoutExpired:
server.kill()
server.wait(timeout=5)
server.communicate(timeout=5)
return 0
if __name__ == "__main__":
raise SystemExit(main())