-
Notifications
You must be signed in to change notification settings - Fork 35
⚡ Bolt: O(1) Blockchain Verification for Officer Visits #601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
02a7a91
52cb608
d39f387
53a2852
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -105,11 +105,14 @@ def generate_visit_hash(visit_data: dict) -> str: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Normalize check_in_time to ISO format string for determinism | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Ensure it matches how SQLite stores/retrieves it (often without TZ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| check_in_time = visit_data.get('check_in_time') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(check_in_time, datetime): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| check_in_time_str = check_in_time.isoformat() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| check_in_time_str = check_in_time.strftime('%Y-%m-%dT%H:%M:%S') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| check_in_time_str = str(check_in_time) if check_in_time else "" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if '+' in check_in_time_str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| check_in_time_str = check_in_time_str.split('+')[0] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+114
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incomplete timezone offset handling — negative UTC offsets use The current logic only strips positive timezone offsets (e.g., 🛠️ Proposed fix to handle both positive and negative offsets check_in_time_str = str(check_in_time) if check_in_time else ""
- if '+' in check_in_time_str:
- check_in_time_str = check_in_time_str.split('+')[0]
+ # Strip timezone offset (both + and - formats)
+ for sep in ('+', '-'):
+ if sep in check_in_time_str and 'T' in check_in_time_str:
+ # Only strip if it looks like a timezone suffix after time
+ parts = check_in_time_str.rsplit(sep, 1)
+ if len(parts) == 2 and ':' in parts[1]:
+ check_in_time_str = parts[0]
+ breakAlternatively, use a more robust approach with regex or dateutil parsing. 🤖 Prompt for AI Agents
Comment on lines
+111
to
+115
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| check_in_time_str = check_in_time.strftime('%Y-%m-%dT%H:%M:%S') | |
| else: | |
| check_in_time_str = str(check_in_time) if check_in_time else "" | |
| if '+' in check_in_time_str: | |
| check_in_time_str = check_in_time_str.split('+')[0] | |
| # If timezone-aware, convert to UTC, then drop tzinfo for a stable naive representation | |
| if check_in_time.tzinfo is not None: | |
| check_in_time_utc = check_in_time.astimezone(timezone.utc).replace(tzinfo=None) | |
| else: | |
| check_in_time_utc = check_in_time | |
| check_in_time_str = check_in_time_utc.strftime('%Y-%m-%dT%H:%M:%S') | |
| else: | |
| raw_time_str = str(check_in_time) if check_in_time else "" | |
| if raw_time_str: | |
| # Try to parse as ISO-8601, handling 'Z' and +/- offsets so equivalent instants hash identically | |
| iso_str = raw_time_str.rstrip() | |
| if iso_str.endswith('Z'): | |
| iso_str = iso_str[:-1] + '+00:00' | |
| try: | |
| parsed_dt = datetime.fromisoformat(iso_str) | |
| if parsed_dt.tzinfo is not None: | |
| parsed_dt = parsed_dt.astimezone(timezone.utc).replace(tzinfo=None) | |
| check_in_time_str = parsed_dt.strftime('%Y-%m-%dT%H:%M:%S') | |
| except ValueError: | |
| # Fallback to previous behavior for non-ISO strings | |
| check_in_time_str = raw_time_str | |
| if '+' in check_in_time_str: | |
| check_in_time_str = check_in_time_str.split('+')[0] | |
| elif check_in_time_str.endswith('Z'): | |
| check_in_time_str = check_in_time_str[:-1] | |
| else: | |
| check_in_time_str = "" |
Copilot
AI
Mar 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hash input is built by concatenating fields with no separators, which can create ambiguous strings (different field tuples producing the same concatenation) and undermine integrity guarantees. Use an unambiguous serialization (e.g., delimiter-separated with escaping, or canonical JSON with explicit field names) before HMACing.
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -27,9 +27,12 @@ | |||||||||||||
| from backend.geofencing_service import ( | ||||||||||||||
| is_within_geofence, | ||||||||||||||
| generate_visit_hash, | ||||||||||||||
| verify_visit_integrity, | ||||||||||||||
| calculate_visit_metrics, | ||||||||||||||
| get_geofencing_service | ||||||||||||||
| ) | ||||||||||||||
| from backend.cache import visit_last_hash_cache | ||||||||||||||
| from backend.schemas import BlockchainVerificationResponse | ||||||||||||||
|
|
||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -93,18 +96,29 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d | |||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| # Create visit record | ||||||||||||||
| check_in_time = datetime.now(timezone.utc) | ||||||||||||||
| # Fix: Strip milliseconds and timezone for deterministic hashing | ||||||||||||||
| check_in_time = datetime.now(timezone.utc).replace(microsecond=0) | ||||||||||||||
|
|
||||||||||||||
| # Blockchain feature: retrieve previous hash for chaining | ||||||||||||||
| # Use cache for O(1) retrieval | ||||||||||||||
| prev_hash = visit_last_hash_cache.get("last_hash") | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Race condition: concurrent check-ins both read the same Prompt for AI agents |
||||||||||||||
| if prev_hash is None: | ||||||||||||||
| # Cache miss: Fetch from DB | ||||||||||||||
|
Comment on lines
+102
to
+106
|
||||||||||||||
| prev_visit = db.query(FieldOfficerVisit.visit_hash).order_by(FieldOfficerVisit.id.desc()).first() | ||||||||||||||
| prev_hash = prev_visit[0] if prev_visit and prev_visit[0] else "" | ||||||||||||||
| visit_last_hash_cache.set(data=prev_hash, key="last_hash") | ||||||||||||||
|
|
||||||||||||||
| visit_data = { | ||||||||||||||
| 'issue_id': request.issue_id, | ||||||||||||||
| 'officer_email': request.officer_email, | ||||||||||||||
| 'check_in_latitude': request.check_in_latitude, | ||||||||||||||
| 'check_in_longitude': request.check_in_longitude, | ||||||||||||||
| 'check_in_time': check_in_time.isoformat(), | ||||||||||||||
| 'visit_notes': request.visit_notes or '' | ||||||||||||||
| 'check_in_time': check_in_time, # Pass datetime object to helper | ||||||||||||||
| 'visit_notes': request.visit_notes or '', | ||||||||||||||
| 'previous_visit_hash': prev_hash | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| # Generate immutable hash | ||||||||||||||
| # Generate immutable HMAC hash with chaining | ||||||||||||||
| visit_hash = generate_visit_hash(visit_data) | ||||||||||||||
|
|
||||||||||||||
| new_visit = FieldOfficerVisit( | ||||||||||||||
|
|
@@ -126,9 +140,14 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d | |||||||||||||
| is_public=True | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| new_visit.previous_visit_hash = prev_hash | ||||||||||||||
|
|
||||||||||||||
| db.add(new_visit) | ||||||||||||||
| db.commit() | ||||||||||||||
| db.refresh(new_visit) | ||||||||||||||
|
|
||||||||||||||
| # Update cache after successful commit | ||||||||||||||
| visit_last_hash_cache.set(data=visit_hash, key="last_hash") | ||||||||||||||
|
Comment on lines
+149
to
+150
|
||||||||||||||
|
|
||||||||||||||
| logger.info( | ||||||||||||||
| f"Officer {request.officer_name} checked in at issue {request.issue_id}. " | ||||||||||||||
|
|
@@ -153,6 +172,8 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d | |||||||||||||
| visit_images=new_visit.visit_images, | ||||||||||||||
| visit_duration_minutes=new_visit.visit_duration_minutes, | ||||||||||||||
| status=new_visit.status, | ||||||||||||||
| visit_hash=new_visit.visit_hash, | ||||||||||||||
| previous_visit_hash=new_visit.previous_visit_hash, | ||||||||||||||
| verified_by=new_visit.verified_by, | ||||||||||||||
| verified_at=new_visit.verified_at, | ||||||||||||||
| is_public=new_visit.is_public, | ||||||||||||||
|
|
@@ -228,6 +249,8 @@ def officer_check_out(request: OfficerCheckOutRequest, db: Session = Depends(get | |||||||||||||
| visit_images=visit.visit_images, | ||||||||||||||
| visit_duration_minutes=visit.visit_duration_minutes, | ||||||||||||||
| status=visit.status, | ||||||||||||||
| visit_hash=visit.visit_hash, | ||||||||||||||
| previous_visit_hash=visit.previous_visit_hash, | ||||||||||||||
| verified_by=visit.verified_by, | ||||||||||||||
| verified_at=visit.verified_at, | ||||||||||||||
| is_public=visit.is_public, | ||||||||||||||
|
|
@@ -478,9 +501,65 @@ def verify_visit( | |||||||||||||
| logger.info(f"Visit {visit_id} verified by {verifier_email}") | ||||||||||||||
|
|
||||||||||||||
| return {"message": "Visit verified successfully", "visit_id": visit_id} | ||||||||||||||
|
|
||||||||||||||
| except HTTPException: | ||||||||||||||
| raise | ||||||||||||||
| except Exception as e: | ||||||||||||||
| logger.error(f"Error verifying visit {visit_id}: {e}", exc_info=True) | ||||||||||||||
| raise HTTPException(status_code=500, detail="Verification failed") | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| @router.get("/field-officer/{visit_id}/blockchain-verify", response_model=BlockchainVerificationResponse) | ||||||||||||||
| def verify_visit_blockchain_integrity(visit_id: int, db: Session = Depends(get_db)): | ||||||||||||||
| """ | ||||||||||||||
| Verify the cryptographic integrity of a visit record using O(1) single-record verification. | ||||||||||||||
| """ | ||||||||||||||
| try: | ||||||||||||||
| visit = db.query( | ||||||||||||||
| FieldOfficerVisit.issue_id, | ||||||||||||||
| FieldOfficerVisit.officer_email, | ||||||||||||||
| FieldOfficerVisit.check_in_latitude, | ||||||||||||||
| FieldOfficerVisit.check_in_longitude, | ||||||||||||||
| FieldOfficerVisit.check_in_time, | ||||||||||||||
| FieldOfficerVisit.visit_notes, | ||||||||||||||
| FieldOfficerVisit.visit_hash, | ||||||||||||||
| FieldOfficerVisit.previous_visit_hash | ||||||||||||||
| ).filter(FieldOfficerVisit.id == visit_id).first() | ||||||||||||||
|
|
||||||||||||||
| if not visit: | ||||||||||||||
| raise HTTPException(status_code=404, detail="Visit not found") | ||||||||||||||
|
|
||||||||||||||
|
Comment on lines
+528
to
+530
|
||||||||||||||
| # Determine previous hash (O(1) from stored column) | ||||||||||||||
| prev_hash = visit.previous_visit_hash or "" | ||||||||||||||
|
|
||||||||||||||
| # Reconstruct visit data for hash verification | ||||||||||||||
| # Normalization of time must match the generation logic | ||||||||||||||
| visit_data = { | ||||||||||||||
| 'issue_id': visit.issue_id, | ||||||||||||||
| 'officer_email': visit.officer_email, | ||||||||||||||
| 'check_in_latitude': visit.check_in_latitude, | ||||||||||||||
| 'check_in_longitude': visit.check_in_longitude, | ||||||||||||||
| 'check_in_time': visit.check_in_time, # Pass datetime object | ||||||||||||||
| 'visit_notes': visit.visit_notes or '', | ||||||||||||||
| 'previous_visit_hash': prev_hash | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| # Verify integrity using the service helper | ||||||||||||||
| is_valid = verify_visit_integrity(visit_data, visit.visit_hash) | ||||||||||||||
| computed_hash = generate_visit_hash(visit_data) | ||||||||||||||
|
Comment on lines
+546
to
+548
|
||||||||||||||
| # Verify integrity using the service helper | |
| is_valid = verify_visit_integrity(visit_data, visit.visit_hash) | |
| computed_hash = generate_visit_hash(visit_data) | |
| # Compute hash once and derive integrity from comparison with stored hash | |
| computed_hash = generate_visit_hash(visit_data) | |
| is_valid = computed_hash == visit.visit_hash |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Missing except HTTPException: raise before the generic except Exception handler. The 404 for a missing visit will be swallowed and returned as a 500 error to the client.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/routers/field_officer.py, line 563:
<comment>Missing `except HTTPException: raise` before the generic `except Exception` handler. The 404 for a missing visit will be swallowed and returned as a 500 error to the client.</comment>
<file context>
@@ -478,9 +501,65 @@ def verify_visit(
+ message=message
+ )
+
+ except Exception as e:
+ logger.error(f"Error verifying visit blockchain for {visit_id}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to verify visit integrity")
</file context>
| except Exception as e: | |
| except HTTPException: | |
| raise | |
| except Exception as e: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing HTTPException re-raise — 404 errors will incorrectly return 500.
The endpoint raises HTTPException(status_code=404) at line 529, but unlike other endpoints in this file (e.g., lines 183-184, 260-261), there's no except HTTPException: raise clause. The generic except Exception at line 563 will catch the 404 and convert it to a 500 error.
🐛 Proposed fix
return BlockchainVerificationResponse(
is_valid=is_valid,
current_hash=visit.visit_hash,
computed_hash=computed_hash,
message=message
)
+ except HTTPException:
+ raise
except Exception as e:
logger.error(f"Error verifying visit blockchain for {visit_id}: {e}", exc_info=True)
- raise HTTPException(status_code=500, detail="Failed to verify visit integrity")
+ raise HTTPException(status_code=500, detail="Failed to verify visit integrity") from e🧰 Tools
🪛 Ruff (0.15.7)
[warning] 512-512: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable
(B008)
[warning] 565-565: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling
(B904)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@backend/routers/field_officer.py` around lines 511 - 565, The
verify_visit_blockchain_integrity endpoint currently catches all exceptions and
will turn deliberate HTTPException(404) into a 500; update the error handling in
verify_visit_blockchain_integrity by adding an explicit except HTTPException:
raise (or re-raise the caught HTTPException) before the generic except Exception
block so that HTTP errors (like the "Visit not found" 404) propagate unchanged,
leaving the final except Exception to handle unexpected errors and log/raise a
500.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
|
|
||
| import sys | ||
| import os | ||
|
|
||
| # Add current directory to path | ||
| sys.path.append(os.getcwd()) | ||
|
|
||
| try: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Move this import smoke check into a test function and raise an assertion instead of exiting the process. Prompt for AI agents |
||
| print("Testing imports...") | ||
| from backend.main import app | ||
| print("Successfully imported FastAPI app.") | ||
|
|
||
| from backend.models import Base | ||
| print("Successfully imported models.") | ||
|
|
||
| from backend.database import engine | ||
| print("Successfully imported database engine.") | ||
|
|
||
| from backend.routers import issues, field_officer, voice | ||
| print("Successfully imported routers.") | ||
|
|
||
| print("All critical imports successful.") | ||
| except Exception as e: | ||
| print(f"IMPORT ERROR: {e}") | ||
| import traceback | ||
| traceback.print_exc() | ||
| sys.exit(1) | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: Datetime normalization is incomplete: only
+timezone offsets are stripped, so-HH:MM/Zformats can generate different hashes for the same instant.Prompt for AI agents