Compare node API keys in constant time on /upload
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy to VM (push) Successful in 46s

/upload compared the per-node API key with a plain !=, which short-circuits on
the first differing byte and so leaks a little information about how much of a
guess was correct.

The reason to fix it is less the timing channel itself -- an HTTP round trip is
noisy -- than the inconsistency: enrollment.py and dynsec.py both went out of
their way to use secrets.compare_digest for the same class of credential, so the
codebase contradicted itself on whether this mattered. Now it does not.

Also coalesces a missing api_key field to "" so compare_digest is never handed
None, which would raise TypeError and turn a malformed node_keys document into a
500 instead of a 401.

Closes logan/server-26#12
This commit is contained in:
Logan Cusano
2026-08-20 03:08:06 -04:00
parent 6dfa5bc66d
commit 5355095c48
+6 -1
View File
@@ -1,3 +1,4 @@
import secrets
from typing import Optional from typing import Optional
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
@@ -36,7 +37,11 @@ async def upload_call_audio(
if not key_doc: if not key_doc:
logger.warning(f"Upload 401: no key_doc in Firestore for node_id={node_id!r}") logger.warning(f"Upload 401: no key_doc in Firestore for node_id={node_id!r}")
raise HTTPException(401, "Invalid node API key") raise HTTPException(401, "Invalid node API key")
if key_doc.get("api_key") != credentials.credentials: # compare_digest, not !=, so the comparison cost does not depend on how many
# leading characters matched. enrollment.py and dynsec.py were explicit about
# this for the same class of credential; this route was the odd one out.
stored_key = key_doc.get("api_key") or ""
if not secrets.compare_digest(stored_key, credentials.credentials):
logger.warning( logger.warning(
f"Upload 401: key mismatch for node_id={node_id!r} " f"Upload 401: key mismatch for node_id={node_id!r} "
f"(received prefix: {credentials.credentials[:8]}...)" f"(received prefix: {credentials.credentials[:8]}...)"