security: bind passkeys to upstream identity (Closes #1448)
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m15s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-27 00:12:57 +00:00
1 parent ff469d96d1
commit 43b0216bda
6 files changed
+240 -39

No files matched your search

+45 -16
View File
@@ -2211,7 +2211,9 @@ async def create_passkey_registration_options(
)
rp_id, _origin = _passkey_relying_party(request)
store = _passkey_store()
existing = await asyncio.to_thread(store.all)
existing = await asyncio.to_thread(
store.all, principal_id=request.state.dashboard_session.principal_id
)
options, challenge = passkeys.registration_options(
rp_id=rp_id,
excluded=[item.credential_id for item in existing],
@@ -2284,6 +2286,7 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
sign_count=verified.sign_count,
device_label=current.device_label,
management_id=current.management_id,
principal_id=request.state.dashboard_session.principal_id,
)
except dashboard_auth.SessionStoreError:
try:
@@ -2305,7 +2308,10 @@ async def verify_passkey_registration(payload: PasskeyCeremony, request: Request
@app.get("/api/v1/passkeys")
async def list_enrolled_passkeys(request: Request):
try:
credentials = await asyncio.to_thread(_passkey_store().all)
credentials = await asyncio.to_thread(
_passkey_store().all,
principal_id=request.state.dashboard_session.principal_id,
)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
@@ -2352,7 +2358,11 @@ async def revoke_enrolled_passkey(
)
store = _passkey_store()
try:
credential = await asyncio.to_thread(store.get_management_id, management_id)
credential = await asyncio.to_thread(
store.get_management_id,
management_id,
principal_id=request.state.dashboard_session.principal_id,
)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
@@ -2418,8 +2428,16 @@ async def revoke_enrolled_passkey(
@app.post("/api/v1/passkeys/authentication/options")
async def create_passkey_authentication_options(request: Request):
try:
principal_id, _principal_login = await _upstream_identity()
except Exception:
return JSONResponse(
{"detail": "Gitea identity is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "5"},
)
store = _passkey_store()
credentials = await asyncio.to_thread(store.all)
credentials = await asyncio.to_thread(store.all, principal_id=principal_id)
if not credentials:
raise HTTPException(status_code=404, detail="No passkeys enrolled")
peer_host = request.client.host if request.client is not None else "unknown"
@@ -2472,7 +2490,9 @@ async def create_passkey_authorization_options(
payload: PasskeyAuthorizationTarget, request: Request
):
store = _passkey_store()
credentials = await asyncio.to_thread(store.all)
credentials = await asyncio.to_thread(
store.all, principal_id=request.state.dashboard_session.principal_id
)
if not credentials:
raise HTTPException(status_code=404, detail="No passkeys enrolled")
rp_id, _origin = _passkey_relying_party(request)
@@ -2509,7 +2529,11 @@ async def verify_passkey_authorization(
action=payload.action,
target=payload.target,
)
stored = await asyncio.to_thread(store.get, credential_id)
stored = await asyncio.to_thread(
store.get,
credential_id,
principal_id=request.state.dashboard_session.principal_id,
)
if not valid or stored is None:
raise HTTPException(status_code=409, detail="Passkey challenge expired or already used")
rp_id, origin = _passkey_relying_party(request)
@@ -2594,6 +2618,14 @@ async def verify_passkey_authentication(
)
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
store = _passkey_store()
try:
principal_id, principal_login = await _upstream_identity()
except Exception:
return JSONResponse(
{"detail": "Gitea identity is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "5"},
)
valid = await asyncio.to_thread(
store.consume_challenge,
challenge,
@@ -2602,7 +2634,9 @@ async def verify_passkey_authentication(
action="sign_in",
target="dashboard",
)
stored = await asyncio.to_thread(store.get, credential_id)
stored = await asyncio.to_thread(
store.get, credential_id, principal_id=principal_id
)
if not valid or stored is None:
try:
await asyncio.to_thread(attempts.record_failure, source)
@@ -2638,14 +2672,6 @@ async def verify_passkey_authentication(
target="sign_in:dashboard",
)
raise ValueError("stale passkey counter")
try:
principal_id, principal_login = await _upstream_identity()
except Exception:
return JSONResponse(
{"detail": "Gitea identity is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "5"},
)
await dashboard_auth.revoke_managed_session(stored.management_id)
signed, session = await asyncio.to_thread(
dashboard_auth.issue_session,
@@ -4049,7 +4075,10 @@ async def sign_out_all_devices(
headers={"Cache-Control": "no-store"},
)
try:
await asyncio.to_thread(_passkey_store().revoke_all_access)
await asyncio.to_thread(
_passkey_store().revoke_all_access,
principal_id=request.state.dashboard_session.principal_id,
)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
+47 -19
View File
@@ -18,6 +18,7 @@ class StoredPasskey:
device_label: str
management_id: str
created_at: int
principal_id: int
class PasskeyStore:
@@ -50,10 +51,19 @@ class PasskeyStore:
sign_count INTEGER NOT NULL,
device_label TEXT NOT NULL,
management_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
created_at INTEGER NOT NULL,
principal_id INTEGER
)
"""
)
credential_columns = {
row[1]
for row in connection.execute("PRAGMA table_info(passkey_credentials)")
}
if "principal_id" not in credential_columns:
connection.execute(
"ALTER TABLE passkey_credentials ADD COLUMN principal_id INTEGER"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_challenges (
@@ -172,12 +182,14 @@ class PasskeyStore:
sign_count: int,
device_label: str,
management_id: str,
principal_id: int,
) -> None:
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
"device_label, management_id, created_at, principal_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
credential_id,
public_key,
@@ -185,41 +197,48 @@ class PasskeyStore:
device_label,
management_id,
int(self.clock()),
principal_id,
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def all(self) -> list[StoredPasskey]:
def all(self, *, principal_id: int) -> list[StoredPasskey]:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials ORDER BY created_at DESC"
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
"created_at, principal_id FROM passkey_credentials "
"WHERE principal_id = ? ORDER BY created_at DESC",
(principal_id,),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return [StoredPasskey(*row) for row in rows]
def get(self, credential_id: bytes) -> StoredPasskey | None:
def get(self, credential_id: bytes, *, principal_id: int) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE credential_id = ?",
(credential_id,),
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
"created_at, principal_id FROM passkey_credentials "
"WHERE credential_id = ? AND principal_id = ?",
(credential_id, principal_id),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def get_management_id(self, management_id: str) -> StoredPasskey | None:
def get_management_id(
self, management_id: str, *, principal_id: int
) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE management_id = ?",
(management_id,),
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
"created_at, principal_id FROM passkey_credentials "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
@@ -326,13 +345,22 @@ class PasskeyStore:
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke_all_access(self) -> None:
"""Atomically remove every passkey, challenge, grant, and active session."""
def revoke_all_access(self, *, principal_id: int) -> None:
"""Atomically remove one principal's passkeys, grants, and active sessions."""
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_credentials")
connection.execute("DELETE FROM passkey_challenges")
connection.execute("DELETE FROM step_up_grants")
connection.execute("DELETE FROM active_sessions")
connection.execute(
"DELETE FROM passkey_credentials WHERE principal_id = ?",
(principal_id,),
)
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash IN ("
"SELECT session_hash FROM active_sessions WHERE principal_id = ?)",
(principal_id,),
)
connection.execute(
"DELETE FROM active_sessions WHERE principal_id = ?",
(principal_id,),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc