tethr-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tethr_engine/__init__.py +1 -0
- tethr_engine/api_keys.py +86 -0
- tethr_engine/audit.py +245 -0
- tethr_engine/auth.py +180 -0
- tethr_engine/cli.py +78 -0
- tethr_engine/config.py +441 -0
- tethr_engine/database.py +530 -0
- tethr_engine/extractor.py +1488 -0
- tethr_engine/identity.py +1244 -0
- tethr_engine/mcp_server.py +244 -0
- tethr_engine/memory.py +341 -0
- tethr_engine/patterns.py +1755 -0
- tethr_engine/personality.py +303 -0
- tethr_engine/pots.py +640 -0
- tethr_engine/pots_linux.py +991 -0
- tethr_engine/server.py +515 -0
- tethr_engine/static/app.js +611 -0
- tethr_engine/static/index.html +131 -0
- tethr_engine/static/style.css +824 -0
- tethr_engine/utils.py +189 -0
- tethr_engine-0.1.0.dist-info/METADATA +54 -0
- tethr_engine-0.1.0.dist-info/RECORD +26 -0
- tethr_engine-0.1.0.dist-info/WHEEL +5 -0
- tethr_engine-0.1.0.dist-info/entry_points.txt +2 -0
- tethr_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- tethr_engine-0.1.0.dist-info/top_level.txt +1 -0
tethr_engine/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""tethr_engine — Standalone Familiar identity engine package."""
|
tethr_engine/api_keys.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api_keys.py — External API key management for network access mode.
|
|
3
|
+
|
|
4
|
+
Keys are prefixed 'fam_' and stored as SHA-256 hashes in the api_keys table.
|
|
5
|
+
The plaintext key is returned only once at creation time — store it immediately.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import secrets
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from tethr_engine.database import get_connection
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _hash_key(key: str) -> str:
|
|
15
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_api_key(name: str) -> dict:
|
|
19
|
+
"""
|
|
20
|
+
Generate a new API key, store hash in DB.
|
|
21
|
+
Returns {"id": int, "key": str, "name": str} — key is plaintext, shown once.
|
|
22
|
+
"""
|
|
23
|
+
key = "fam_" + secrets.token_urlsafe(32)
|
|
24
|
+
key_hash = _hash_key(key)
|
|
25
|
+
conn = get_connection()
|
|
26
|
+
try:
|
|
27
|
+
cursor = conn.execute(
|
|
28
|
+
"INSERT INTO api_keys (key_hash, name, created_at, active) VALUES (?, ?, ?, 1)",
|
|
29
|
+
(key_hash, name, datetime.now(timezone.utc).isoformat()),
|
|
30
|
+
)
|
|
31
|
+
conn.commit()
|
|
32
|
+
key_id = cursor.lastrowid
|
|
33
|
+
finally:
|
|
34
|
+
conn.close()
|
|
35
|
+
return {"id": key_id, "key": key, "name": name}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def verify_api_key(key: str) -> int | None:
|
|
39
|
+
"""
|
|
40
|
+
Validate a raw API key. Returns the api_key id if active, None otherwise.
|
|
41
|
+
Updates last_used on success.
|
|
42
|
+
"""
|
|
43
|
+
key_hash = _hash_key(key)
|
|
44
|
+
conn = get_connection()
|
|
45
|
+
try:
|
|
46
|
+
row = conn.execute(
|
|
47
|
+
"SELECT id FROM api_keys WHERE key_hash = ? AND active = 1",
|
|
48
|
+
(key_hash,),
|
|
49
|
+
).fetchone()
|
|
50
|
+
if row:
|
|
51
|
+
conn.execute(
|
|
52
|
+
"UPDATE api_keys SET last_used = ? WHERE id = ?",
|
|
53
|
+
(datetime.now(timezone.utc).isoformat(), row["id"]),
|
|
54
|
+
)
|
|
55
|
+
conn.commit()
|
|
56
|
+
return row["id"]
|
|
57
|
+
finally:
|
|
58
|
+
conn.close()
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def revoke_api_key(key_id: int) -> bool:
|
|
63
|
+
"""Deactivate an API key by id. Returns True if found and deactivated."""
|
|
64
|
+
conn = get_connection()
|
|
65
|
+
try:
|
|
66
|
+
cursor = conn.execute(
|
|
67
|
+
"UPDATE api_keys SET active = 0 WHERE id = ? AND active = 1",
|
|
68
|
+
(key_id,),
|
|
69
|
+
)
|
|
70
|
+
conn.commit()
|
|
71
|
+
return cursor.rowcount > 0
|
|
72
|
+
finally:
|
|
73
|
+
conn.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_api_keys() -> list:
|
|
77
|
+
"""Return all active API keys (key_hash excluded)."""
|
|
78
|
+
conn = get_connection()
|
|
79
|
+
try:
|
|
80
|
+
rows = conn.execute(
|
|
81
|
+
"SELECT id, name, created_at, last_used "
|
|
82
|
+
"FROM api_keys WHERE active = 1 ORDER BY created_at DESC"
|
|
83
|
+
).fetchall()
|
|
84
|
+
return [dict(r) for r in rows]
|
|
85
|
+
finally:
|
|
86
|
+
conn.close()
|
tethr_engine/audit.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
audit.py — Audit logging, input sanitization middleware, and injection detection.
|
|
3
|
+
|
|
4
|
+
Audit log: immutable record of security events.
|
|
5
|
+
Sanitization middleware: strips null bytes and control characters, then scans
|
|
6
|
+
all string fields for prompt injection patterns before routing.
|
|
7
|
+
detect_injection(): flags instruction override, role hijack, system prompt
|
|
8
|
+
extraction, jailbreak, and identity replacement attempts.
|
|
9
|
+
"""
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
from tethr_engine.database import get_connection
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── audit log ─────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def log_audit(
|
|
22
|
+
action: str,
|
|
23
|
+
user_id: str = None,
|
|
24
|
+
details: str = None,
|
|
25
|
+
ip_address: str = None,
|
|
26
|
+
):
|
|
27
|
+
"""Write a security event to the audit_log table."""
|
|
28
|
+
conn = get_connection()
|
|
29
|
+
try:
|
|
30
|
+
conn.execute(
|
|
31
|
+
"INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)",
|
|
32
|
+
(user_id, action, details, ip_address)
|
|
33
|
+
)
|
|
34
|
+
conn.commit()
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(f"[audit] WARN — failed to write audit event '{action}': {e}")
|
|
37
|
+
finally:
|
|
38
|
+
conn.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── prompt injection detection ────────────────────────────
|
|
42
|
+
# Patterns are ordered within each category by specificity.
|
|
43
|
+
# confidence: 3 = high certainty (exact known phrase), 2 = likely, 1 = possible.
|
|
44
|
+
|
|
45
|
+
_INJECTION_PATTERNS: list[tuple[str, int, re.Pattern]] = []
|
|
46
|
+
|
|
47
|
+
def _build_injection_patterns():
|
|
48
|
+
"""Compile injection detection patterns once at import time."""
|
|
49
|
+
raw = [
|
|
50
|
+
# (threat_type, confidence, pattern_string)
|
|
51
|
+
# Instruction override
|
|
52
|
+
("instruction_override", 3, r"ignore\s+previous\s+instructions?"),
|
|
53
|
+
("instruction_override", 3, r"ignore\s+all\s+(previous\s+)?instructions?"),
|
|
54
|
+
("instruction_override", 3, r"forget\s+your\s+instructions?"),
|
|
55
|
+
("instruction_override", 3, r"new\s+instructions?[\s:]+"),
|
|
56
|
+
("instruction_override", 2, r"\bdisregard\b"),
|
|
57
|
+
# Role hijack
|
|
58
|
+
("role_hijack", 3, r"you\s+are\s+now\s+\w"),
|
|
59
|
+
("role_hijack", 3, r"you\s+have\s+been\s+reprogrammed"),
|
|
60
|
+
("role_hijack", 3, r"your\s+new\s+name\s+is\b"),
|
|
61
|
+
("role_hijack", 2, r"\bpretend\s+you\s+are\b"),
|
|
62
|
+
("role_hijack", 2, r"\bact\s+as\s+(an?\s+)?\w"),
|
|
63
|
+
# System prompt extraction
|
|
64
|
+
("system_prompt_extraction", 3, r"repeat\s+your\s+instructions?"),
|
|
65
|
+
("system_prompt_extraction", 3, r"show\s+(me\s+)?your\s+system\s+prompt"),
|
|
66
|
+
("system_prompt_extraction", 3, r"print\s+your\s+(system\s+)?prompt"),
|
|
67
|
+
("system_prompt_extraction", 2, r"what\s+are\s+your\s+instructions?"),
|
|
68
|
+
("system_prompt_extraction", 2, r"reveal\s+your\s+(system\s+)?prompt"),
|
|
69
|
+
# Jailbreak
|
|
70
|
+
("jailbreak", 3, r"\bDAN\b"),
|
|
71
|
+
("jailbreak", 3, r"\bdeveloper\s+mode\b"),
|
|
72
|
+
("jailbreak", 3, r"\bunrestricted\s+mode\b"),
|
|
73
|
+
("jailbreak", 2, r"\bno\s+restrictions?\b"),
|
|
74
|
+
("jailbreak", 2, r"\bwithout\s+filters?\b"),
|
|
75
|
+
# Identity replacement
|
|
76
|
+
("identity_replacement", 3, r"your\s+real\s+name\s+is\b"),
|
|
77
|
+
("identity_replacement", 3, r"your\s+true\s+identity\b"),
|
|
78
|
+
("identity_replacement", 3, r"you\s+were\s+actually\b"),
|
|
79
|
+
]
|
|
80
|
+
for threat_type, confidence, pattern in raw:
|
|
81
|
+
_INJECTION_PATTERNS.append(
|
|
82
|
+
(threat_type, confidence, re.compile(pattern, re.IGNORECASE))
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_build_injection_patterns()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def detect_injection(text: str) -> dict:
|
|
89
|
+
"""
|
|
90
|
+
Scan a string for prompt injection patterns.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
{
|
|
94
|
+
"detected": bool,
|
|
95
|
+
"threat_type": str | None, # first match wins
|
|
96
|
+
"confidence": int | None, # 1–3
|
|
97
|
+
"matched_pattern": str | None, # the matched substring
|
|
98
|
+
}
|
|
99
|
+
"""
|
|
100
|
+
for threat_type, confidence, pattern in _INJECTION_PATTERNS:
|
|
101
|
+
m = pattern.search(text)
|
|
102
|
+
if m:
|
|
103
|
+
return {
|
|
104
|
+
"detected": True,
|
|
105
|
+
"threat_type": threat_type,
|
|
106
|
+
"confidence": confidence,
|
|
107
|
+
"matched_pattern": m.group(0),
|
|
108
|
+
}
|
|
109
|
+
return {"detected": False, "threat_type": None, "confidence": None, "matched_pattern": None}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _collect_strings(value) -> list[str]:
|
|
113
|
+
"""Recursively collect all string values from a JSON-decoded structure."""
|
|
114
|
+
if isinstance(value, str):
|
|
115
|
+
return [value]
|
|
116
|
+
if isinstance(value, dict):
|
|
117
|
+
out = []
|
|
118
|
+
for v in value.values():
|
|
119
|
+
out.extend(_collect_strings(v))
|
|
120
|
+
return out
|
|
121
|
+
if isinstance(value, list):
|
|
122
|
+
out = []
|
|
123
|
+
for item in value:
|
|
124
|
+
out.extend(_collect_strings(item))
|
|
125
|
+
return out
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── input sanitization ────────────────────────────────────
|
|
130
|
+
# Strips: null bytes (\x00), ASCII control chars (0x01–0x1F except \t \n \r),
|
|
131
|
+
# DEL (0x7F), and Unicode zero-width / specials.
|
|
132
|
+
|
|
133
|
+
_STRIP_PATTERN = re.compile(
|
|
134
|
+
r"[\x00\x01-\x08\x0b\x0c\x0e-\x1f\x7f]"
|
|
135
|
+
r"|\u200b|\u200c|\u200d|\u200e|\u200f"
|
|
136
|
+
r"|[\ufff0-\uffff]"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sanitize_str(value: str) -> tuple[str, bool]:
|
|
141
|
+
cleaned = _STRIP_PATTERN.sub("", value)
|
|
142
|
+
return cleaned, cleaned != value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _sanitize_value(value):
|
|
146
|
+
"""Recursively sanitize strings within dicts, lists, and scalars."""
|
|
147
|
+
if isinstance(value, str):
|
|
148
|
+
return _sanitize_str(value)
|
|
149
|
+
if isinstance(value, dict):
|
|
150
|
+
result = {}
|
|
151
|
+
changed = False
|
|
152
|
+
for k, v in value.items():
|
|
153
|
+
new_v, c = _sanitize_value(v)
|
|
154
|
+
result[k] = new_v
|
|
155
|
+
changed = changed or c
|
|
156
|
+
return result, changed
|
|
157
|
+
if isinstance(value, list):
|
|
158
|
+
result = []
|
|
159
|
+
changed = False
|
|
160
|
+
for item in value:
|
|
161
|
+
new_item, c = _sanitize_value(item)
|
|
162
|
+
result.append(new_item)
|
|
163
|
+
changed = changed or c
|
|
164
|
+
return result, changed
|
|
165
|
+
return value, False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── FastAPI middleware ─────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
from fastapi import Request
|
|
171
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
172
|
+
import json
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SanitizationMiddleware(BaseHTTPMiddleware):
|
|
176
|
+
"""
|
|
177
|
+
For every JSON request:
|
|
178
|
+
1. Strip control characters from all string values.
|
|
179
|
+
2. Scan all string values for prompt injection patterns.
|
|
180
|
+
3. Log both events to security_log (flag only — never block).
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
async def dispatch(self, request: Request, call_next):
|
|
184
|
+
content_type = request.headers.get("content-type", "")
|
|
185
|
+
|
|
186
|
+
if "application/json" in content_type:
|
|
187
|
+
try:
|
|
188
|
+
body_bytes = await request.body()
|
|
189
|
+
if body_bytes:
|
|
190
|
+
data = json.loads(body_bytes)
|
|
191
|
+
|
|
192
|
+
# 1. Sanitize control characters
|
|
193
|
+
cleaned, changed = _sanitize_value(data)
|
|
194
|
+
if changed:
|
|
195
|
+
self._log_security(request, "control_chars_stripped", None, None)
|
|
196
|
+
request._body = json.dumps(cleaned).encode()
|
|
197
|
+
data = cleaned # scan the cleaned version for injections
|
|
198
|
+
|
|
199
|
+
# 2. Injection detection — scan every string field
|
|
200
|
+
for text in _collect_strings(data):
|
|
201
|
+
result = detect_injection(text)
|
|
202
|
+
if result["detected"]:
|
|
203
|
+
self._log_security(
|
|
204
|
+
request,
|
|
205
|
+
"injection_detected",
|
|
206
|
+
result["threat_type"],
|
|
207
|
+
result["confidence"],
|
|
208
|
+
)
|
|
209
|
+
break # one log entry per request is enough
|
|
210
|
+
|
|
211
|
+
except (json.JSONDecodeError, Exception):
|
|
212
|
+
pass # malformed JSON — FastAPI validation handles it
|
|
213
|
+
|
|
214
|
+
return await call_next(request)
|
|
215
|
+
|
|
216
|
+
def _log_security(
|
|
217
|
+
self,
|
|
218
|
+
request: Request,
|
|
219
|
+
detail: str,
|
|
220
|
+
threat_type: str | None,
|
|
221
|
+
confidence: int | None,
|
|
222
|
+
):
|
|
223
|
+
ip = None
|
|
224
|
+
if request.client:
|
|
225
|
+
ip = request.client.host
|
|
226
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
227
|
+
if forwarded:
|
|
228
|
+
ip = forwarded.split(",")[0].strip()
|
|
229
|
+
|
|
230
|
+
detail_str = detail
|
|
231
|
+
if threat_type:
|
|
232
|
+
detail_str += f" threat={threat_type} confidence={confidence}"
|
|
233
|
+
|
|
234
|
+
conn = get_connection()
|
|
235
|
+
try:
|
|
236
|
+
conn.execute(
|
|
237
|
+
"INSERT INTO security_log (ip_address, endpoint, detail, threat_type) "
|
|
238
|
+
"VALUES (?, ?, ?, ?)",
|
|
239
|
+
(ip, str(request.url.path), detail_str, threat_type)
|
|
240
|
+
)
|
|
241
|
+
conn.commit()
|
|
242
|
+
except sqlite3.Error as e:
|
|
243
|
+
logger.warning(f"DB error: {e}")
|
|
244
|
+
finally:
|
|
245
|
+
conn.close()
|
tethr_engine/auth.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth.py — JWT creation/validation, password hashing, token management.
|
|
3
|
+
|
|
4
|
+
Access tokens: 30-day JWT stored in httpOnly cookie.
|
|
5
|
+
Refresh tokens: 7-day opaque token, SHA-256 hashed in `tokens` table.
|
|
6
|
+
Passwords: bcrypt-hashed, never logged.
|
|
7
|
+
"""
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import bcrypt
|
|
16
|
+
from fastapi import Cookie, Depends, HTTPException, Request
|
|
17
|
+
from jose import JWTError, jwt
|
|
18
|
+
|
|
19
|
+
from tethr_engine.database import get_connection
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# ── constants ─────────────────────────────────────────────
|
|
24
|
+
ALGORITHM = "HS256"
|
|
25
|
+
ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 days
|
|
26
|
+
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
|
27
|
+
|
|
28
|
+
# Lazy-loaded from config to avoid circular import at module load time
|
|
29
|
+
_SECRET_KEY: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_secret() -> str:
|
|
33
|
+
global _SECRET_KEY
|
|
34
|
+
if _SECRET_KEY is None:
|
|
35
|
+
from tethr_engine.config import get_config
|
|
36
|
+
_SECRET_KEY = get_config().get("server_secret", "")
|
|
37
|
+
if not _SECRET_KEY:
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"server_secret missing from config.yaml — "
|
|
40
|
+
"restart the server to auto-generate one"
|
|
41
|
+
)
|
|
42
|
+
return _SECRET_KEY
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── password utilities ────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def hash_password(password: str) -> str:
|
|
48
|
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verify_password(password: str, hashed: str) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
return bcrypt.checkpw(password.encode(), hashed.encode())
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.warning(f"Password verification error: {e}")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_password_strength(password: str):
|
|
60
|
+
"""Raises ValueError if password doesn't meet requirements."""
|
|
61
|
+
if len(password) < 8:
|
|
62
|
+
raise ValueError("Password must be at least 8 characters")
|
|
63
|
+
if not re.search(r"[A-Za-z]", password):
|
|
64
|
+
raise ValueError("Password must contain at least one letter")
|
|
65
|
+
if not re.search(r"\d", password):
|
|
66
|
+
raise ValueError("Password must contain at least one number")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── token creation ────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def create_access_token(user_id: str) -> str:
|
|
72
|
+
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
73
|
+
payload = {"sub": user_id, "exp": expire, "type": "access"}
|
|
74
|
+
return jwt.encode(payload, _get_secret(), algorithm=ALGORITHM)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_refresh_token(user_id: str) -> str:
|
|
78
|
+
"""Creates opaque refresh token, stores SHA-256 hash in DB. Returns raw token."""
|
|
79
|
+
token = secrets.token_urlsafe(32)
|
|
80
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
81
|
+
expires_at = (datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)).isoformat()
|
|
82
|
+
|
|
83
|
+
conn = get_connection()
|
|
84
|
+
try:
|
|
85
|
+
conn.execute(
|
|
86
|
+
"INSERT INTO tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
|
|
87
|
+
(user_id, token_hash, expires_at)
|
|
88
|
+
)
|
|
89
|
+
conn.commit()
|
|
90
|
+
finally:
|
|
91
|
+
conn.close()
|
|
92
|
+
|
|
93
|
+
return token
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── token verification ────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def verify_access_token(token: str) -> str:
|
|
99
|
+
"""Returns user_id or raises 401."""
|
|
100
|
+
try:
|
|
101
|
+
payload = jwt.decode(token, _get_secret(), algorithms=[ALGORITHM])
|
|
102
|
+
if payload.get("type") != "access":
|
|
103
|
+
raise JWTError("wrong token type")
|
|
104
|
+
user_id = payload.get("sub")
|
|
105
|
+
if not user_id:
|
|
106
|
+
raise JWTError("missing sub")
|
|
107
|
+
return user_id
|
|
108
|
+
except JWTError:
|
|
109
|
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def verify_refresh_token(token: str) -> str:
|
|
113
|
+
"""Returns user_id or raises 401."""
|
|
114
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
115
|
+
conn = get_connection()
|
|
116
|
+
try:
|
|
117
|
+
row = conn.execute(
|
|
118
|
+
"SELECT user_id, expires_at, revoked FROM tokens WHERE token_hash = ?",
|
|
119
|
+
(token_hash,)
|
|
120
|
+
).fetchone()
|
|
121
|
+
finally:
|
|
122
|
+
conn.close()
|
|
123
|
+
|
|
124
|
+
if not row:
|
|
125
|
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
126
|
+
if row["revoked"]:
|
|
127
|
+
raise HTTPException(status_code=401, detail="Refresh token has been revoked")
|
|
128
|
+
|
|
129
|
+
expires = datetime.fromisoformat(row["expires_at"]).replace(tzinfo=timezone.utc)
|
|
130
|
+
if datetime.now(timezone.utc) > expires:
|
|
131
|
+
raise HTTPException(status_code=401, detail="Refresh token expired")
|
|
132
|
+
|
|
133
|
+
return row["user_id"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def revoke_refresh_token(token: str):
|
|
137
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
138
|
+
conn = get_connection()
|
|
139
|
+
try:
|
|
140
|
+
conn.execute("UPDATE tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,))
|
|
141
|
+
conn.commit()
|
|
142
|
+
finally:
|
|
143
|
+
conn.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── FastAPI dependency ─────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def get_current_user(request: Request) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Dependency — accepts JWT from either:
|
|
151
|
+
1. Authorization: Bearer <token> (desktop / device clients)
|
|
152
|
+
2. httpOnly access_token cookie (web clients)
|
|
153
|
+
Raises 401 if missing or invalid.
|
|
154
|
+
"""
|
|
155
|
+
auth_header = request.headers.get("Authorization", "")
|
|
156
|
+
if auth_header.startswith("Bearer "):
|
|
157
|
+
return verify_access_token(auth_header[7:])
|
|
158
|
+
token = request.cookies.get("access_token")
|
|
159
|
+
if not token:
|
|
160
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
161
|
+
return verify_access_token(token)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_current_admin(request: Request) -> str:
|
|
165
|
+
"""
|
|
166
|
+
Dependency — same as get_current_user but also checks admin flag.
|
|
167
|
+
Raises 403 if user is not an admin.
|
|
168
|
+
"""
|
|
169
|
+
user_id = get_current_user(request)
|
|
170
|
+
conn = get_connection()
|
|
171
|
+
try:
|
|
172
|
+
row = conn.execute(
|
|
173
|
+
"SELECT is_admin FROM users WHERE id = ?", (user_id,)
|
|
174
|
+
).fetchone()
|
|
175
|
+
finally:
|
|
176
|
+
conn.close()
|
|
177
|
+
|
|
178
|
+
if not row or not row["is_admin"]:
|
|
179
|
+
raise HTTPException(status_code=403, detail="Admin access required")
|
|
180
|
+
return user_id
|
tethr_engine/cli.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""cli.py — tethr command-line interface."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser = argparse.ArgumentParser(
|
|
8
|
+
prog="tethr",
|
|
9
|
+
description="Tethr Engine — identity and memory server",
|
|
10
|
+
)
|
|
11
|
+
sub = parser.add_subparsers(dest="command")
|
|
12
|
+
|
|
13
|
+
# tethr start
|
|
14
|
+
start_p = sub.add_parser("start", help="Start the tethr engine server")
|
|
15
|
+
start_p.add_argument(
|
|
16
|
+
"--host",
|
|
17
|
+
default="127.0.0.1",
|
|
18
|
+
help="Bind host (default: 127.0.0.1; use 0.0.0.0 for network access)",
|
|
19
|
+
)
|
|
20
|
+
start_p.add_argument(
|
|
21
|
+
"--port", type=int, default=8001, help="Port (default: 8001)"
|
|
22
|
+
)
|
|
23
|
+
start_p.add_argument(
|
|
24
|
+
"--reload", action="store_true", help="Enable auto-reload for development"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# tethr status
|
|
28
|
+
sub.add_parser("status", help="Check server health")
|
|
29
|
+
|
|
30
|
+
# tethr version
|
|
31
|
+
sub.add_parser("version", help="Print version and exit")
|
|
32
|
+
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
if args.command == "start":
|
|
36
|
+
_cmd_start(args)
|
|
37
|
+
elif args.command == "status":
|
|
38
|
+
_cmd_status()
|
|
39
|
+
elif args.command == "version":
|
|
40
|
+
_cmd_version()
|
|
41
|
+
else:
|
|
42
|
+
parser.print_help()
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cmd_start(args):
|
|
47
|
+
import uvicorn
|
|
48
|
+
|
|
49
|
+
uvicorn.run(
|
|
50
|
+
"tethr_engine.server:app",
|
|
51
|
+
host=args.host,
|
|
52
|
+
port=args.port,
|
|
53
|
+
reload=args.reload,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cmd_status():
|
|
58
|
+
import requests
|
|
59
|
+
|
|
60
|
+
url = "http://127.0.0.1:8001/health"
|
|
61
|
+
try:
|
|
62
|
+
resp = requests.get(url, timeout=5)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
print(resp.json())
|
|
65
|
+
except requests.ConnectionError:
|
|
66
|
+
print(f"ERROR: Could not connect to {url} — is the server running?")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
print(f"ERROR: {exc}")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cmd_version():
|
|
74
|
+
try:
|
|
75
|
+
from importlib.metadata import version
|
|
76
|
+
print(f"tethr-engine {version('tethr-engine')}")
|
|
77
|
+
except Exception:
|
|
78
|
+
print("tethr-engine 0.1.0")
|