-
Notifications
You must be signed in to change notification settings - Fork 35
β‘ Bolt: Implement blockchain chaining for field officer visits #609
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
Changes from all commits
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 |
|---|---|---|
|
|
@@ -95,7 +95,7 @@ def generate_visit_hash(visit_data: dict) -> str: | |
| Generate a tamper-resistant HMAC hash for visit data (blockchain-like integrity). | ||
|
|
||
| Uses HMAC-SHA256 with server secret to prevent forgery. | ||
| Normalizes datetime to ISO format for deterministic hashing. | ||
| Normalizes datetime to UTC ISO format for deterministic hashing. | ||
|
|
||
| Args: | ||
| visit_data: Dictionary containing visit information | ||
|
|
@@ -104,21 +104,29 @@ def generate_visit_hash(visit_data: dict) -> str: | |
| HMAC-SHA256 hash of visit data | ||
| """ | ||
| try: | ||
| # Normalize check_in_time to ISO format string for determinism | ||
| # Normalize check_in_time to UTC ISO format string for determinism | ||
| # Ensure microseconds are stripped for consistent comparison across DBs | ||
| check_in_time = visit_data.get('check_in_time') | ||
| if isinstance(check_in_time, datetime): | ||
| check_in_time_str = check_in_time.isoformat() | ||
| # Normalize to UTC and strip microseconds for consistency | ||
| if check_in_time.tzinfo is None: | ||
| check_in_time = check_in_time.replace(tzinfo=timezone.utc) | ||
| else: | ||
| check_in_time = check_in_time.astimezone(timezone.utc) | ||
|
|
||
| check_in_time_str = check_in_time.replace(microsecond=0).strftime('%Y-%m-%dT%H:%M:%S') | ||
| else: | ||
| check_in_time_str = str(check_in_time) if check_in_time else "" | ||
|
|
||
| # Create a deterministic string from visit data | ||
| # Create a deterministic string from visit data, including previous hash for chaining | ||
| data_string = ( | ||
| f"{visit_data.get('issue_id')}" | ||
| f"{visit_data.get('officer_email')}" | ||
| f"{visit_data.get('check_in_latitude')}" | ||
| f"{visit_data.get('check_in_longitude')}" | ||
| f"{check_in_time_str}" | ||
| f"{visit_data.get('visit_notes', '')}" | ||
| f"{visit_data.get('previous_visit_hash', '')}" | ||
| ) | ||
|
Comment on lines
+107
to
130
|
||
|
|
||
| # Generate HMAC-SHA256 hash for tamper-resistance | ||
|
|
||
| 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,20 +96,34 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d | |||||||||
| ) | ||||||||||
|
|
||||||||||
| # Create visit record | ||||||||||
| check_in_time = datetime.now(timezone.utc) | ||||||||||
|
|
||||||||||
| # Normalize check_in_time: strip microseconds for deterministic hashing across DBs | ||||||||||
| check_in_time = datetime.now(timezone.utc).replace(microsecond=0) | ||||||||||
|
|
||||||||||
| # Blockchain feature: calculate integrity hash for the visit | ||||||||||
| # Performance Boost: Use thread-safe cache to eliminate DB query for last hash | ||||||||||
| prev_hash = visit_last_hash_cache.get("last_hash") | ||||||||||
| if prev_hash is None: | ||||||||||
| # Cache miss: Fetch only the last hash from DB | ||||||||||
| 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") | ||||||||||
|
Comment on lines
+104
to
+109
|
||||||||||
|
|
||||||||||
|
Comment on lines
+104
to
+110
|
||||||||||
| 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, | ||||||||||
| 'visit_notes': request.visit_notes or '', | ||||||||||
| 'previous_visit_hash': prev_hash | ||||||||||
| } | ||||||||||
|
|
||||||||||
| # Generate immutable hash | ||||||||||
| visit_hash = generate_visit_hash(visit_data) | ||||||||||
|
|
||||||||||
| # Update cache for next visit | ||||||||||
| visit_last_hash_cache.set(data=visit_hash, key="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: Do not update Prompt for AI agents
Comment on lines
123
to
+125
|
||||||||||
| # Update cache for next visit | |
| visit_last_hash_cache.set(data=visit_hash, key="last_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.
P2: Blockchain verification currently checks row integrity only; it should also verify the previous_visit_hash link against the actual previous visit record.
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 534:
<comment>Blockchain verification currently checks row integrity only; it should also verify the `previous_visit_hash` link against the actual previous visit record.</comment>
<file context>
@@ -484,3 +502,54 @@ def verify_visit(
+ }
+
+ # Use helper for verification
+ is_valid = verify_visit_integrity(visit_data, visit.visit_hash)
+
+ # For the response, we need the computed hash
</file context>
| is_valid = verify_visit_integrity(visit_data, visit.visit_hash) | |
| previous_hash_in_db = db.query(FieldOfficerVisit.visit_hash).filter(FieldOfficerVisit.id < visit.id).order_by(FieldOfficerVisit.id.desc()).scalar() or "" | |
| chain_link_valid = prev_hash == previous_hash_in_db | |
| is_valid = chain_link_valid and verify_visit_integrity(visit_data, visit.visit_hash) |
Copilot
AI
Mar 29, 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.
/blockchain-verify recomputes the visit hash using the stored previous_visit_hash, but it never checks that previous_visit_hash actually matches the current visit_hash of the previous visit record. As a result, tampering with an earlier visit wonβt invalidate later visits when verified individually. If the goal is chain continuity, add an O(1) lookup of the previous record (or store a previous_visit_id) and compare its visit_hash to previous_visit_hash as part of verification.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import pytest | ||
| from fastapi.testclient import TestClient | ||
| from sqlalchemy import text | ||
| from sqlalchemy.orm import Session | ||
| from datetime import datetime, timezone | ||
| import hashlib | ||
|
|
||
| from backend.main import app | ||
| from backend.database import get_db, Base, engine | ||
| from backend.models import Issue, FieldOfficerVisit | ||
| from backend.cache import visit_last_hash_cache | ||
|
|
||
| # Use an isolated SQLite database for blockchain tests | ||
| SQLALCHEMY_DATABASE_URL = "sqlite:///./test_blockchain_visit.db" | ||
|
|
||
| @pytest.fixture(scope="module") | ||
| def client(): | ||
| # Setup: Create tables in the test database | ||
| Base.metadata.create_all(bind=engine) | ||
|
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: The fixture is not actually isolated to the declared test database; it uses the global app engine for create/drop operations. Prompt for AI agents |
||
| with TestClient(app) as c: | ||
| yield c | ||
| # Teardown: Remove the test database file if needed, | ||
| # but here we just rely on the fact it's a separate file or in-memory | ||
| Base.metadata.drop_all(bind=engine) | ||
|
Comment on lines
+13
to
+24
|
||
|
|
||
| def test_visit_blockchain_chaining(client): | ||
| # Clear cache for deterministic test | ||
| visit_last_hash_cache.clear() | ||
|
|
||
| # 1. Create a test issue | ||
| db = next(get_db()) | ||
| issue = Issue( | ||
|
Comment on lines
+30
to
+32
|
||
| description="Pothole on Main St", | ||
| category="Road", | ||
| latitude=18.5204, | ||
| longitude=73.8567, | ||
| status="open" | ||
| ) | ||
| db.add(issue) | ||
| db.commit() | ||
| db.refresh(issue) | ||
|
|
||
| # 2. First check-in (Root of the chain) | ||
| checkin1 = { | ||
| "issue_id": issue.id, | ||
| "officer_email": "officer1@city.gov", | ||
| "officer_name": "John Doe", | ||
| "check_in_latitude": 18.5205, | ||
| "check_in_longitude": 73.8568, | ||
| "visit_notes": "First visit", | ||
| "geofence_radius_meters": 100.0 | ||
| } | ||
| response1 = client.post("/api/field-officer/check-in", json=checkin1) | ||
| assert response1.status_code == 200 | ||
| data1 = response1.json() | ||
| visit1_id = data1["id"] | ||
| visit1_hash = data1.get("visit_hash") # Note: Visit hash is not in FieldOfficerVisitResponse by default in schemas.py? | ||
|
|
||
| # Re-fetch from DB to check visit_hash and previous_visit_hash | ||
| v1 = db.query(FieldOfficerVisit).filter(FieldOfficerVisit.id == visit1_id).first() | ||
| assert v1.visit_hash is not None | ||
| assert v1.previous_visit_hash == "" | ||
|
|
||
| # 3. Second check-in (Chained to first) | ||
| checkin2 = { | ||
| "issue_id": issue.id, | ||
| "officer_email": "officer2@city.gov", | ||
| "officer_name": "Jane Smith", | ||
| "check_in_latitude": 18.5204, | ||
| "check_in_longitude": 73.8567, | ||
| "visit_notes": "Second visit", | ||
| "geofence_radius_meters": 100.0 | ||
| } | ||
| response2 = client.post("/api/field-officer/check-in", json=checkin2) | ||
| assert response2.status_code == 200 | ||
| data2 = response2.json() | ||
| visit2_id = data2["id"] | ||
|
|
||
| v2 = db.query(FieldOfficerVisit).filter(FieldOfficerVisit.id == visit2_id).first() | ||
| assert v2.visit_hash is not None | ||
| assert v2.previous_visit_hash == v1.visit_hash | ||
|
|
||
| # 4. Verify integrity via API | ||
| verify_resp1 = client.get(f"/api/field-officer/{visit1_id}/blockchain-verify") | ||
| assert verify_resp1.status_code == 200 | ||
| assert verify_resp1.json()["is_valid"] is True | ||
|
|
||
| verify_resp2 = client.get(f"/api/field-officer/{visit2_id}/blockchain-verify") | ||
| assert verify_resp2.status_code == 200 | ||
| assert verify_resp2.json()["is_valid"] is True | ||
|
|
||
| # 5. Simulate tampering | ||
| v2.visit_notes = "TAMPERED NOTES" | ||
| db.commit() | ||
|
|
||
| verify_tampered = client.get(f"/api/field-officer/{visit2_id}/blockchain-verify") | ||
| assert verify_tampered.status_code == 200 | ||
| assert verify_tampered.json()["is_valid"] is False | ||
| assert "Integrity check failed" in verify_tampered.json()["message"] | ||
|
|
||
| def test_cache_miss_recovery(client): | ||
| # 1. Create a visit | ||
| db = next(get_db()) | ||
| issue = db.query(Issue).first() | ||
|
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: This test has an inter-test dependency by assuming an Issue already exists; it can fail when run in isolation. Prompt for AI agents |
||
|
|
||
| checkin = { | ||
| "issue_id": issue.id, | ||
| "officer_email": "officer3@city.gov", | ||
| "officer_name": "Officer Cache", | ||
| "check_in_latitude": 18.5204, | ||
| "check_in_longitude": 73.8567, | ||
| "visit_notes": "Cache test", | ||
| "geofence_radius_meters": 100.0 | ||
| } | ||
| client.post("/api/field-officer/check-in", json=checkin) | ||
|
|
||
| last_visit = db.query(FieldOfficerVisit).order_by(FieldOfficerVisit.id.desc()).first() | ||
| last_hash = last_visit.visit_hash | ||
|
|
||
| # 2. Clear cache | ||
| visit_last_hash_cache.clear() | ||
|
|
||
| # 3. Next check-in should still chain correctly by fetching from DB | ||
| checkin_next = { | ||
| "issue_id": issue.id, | ||
| "officer_email": "officer4@city.gov", | ||
| "officer_name": "Officer Recovery", | ||
| "check_in_latitude": 18.5204, | ||
| "check_in_longitude": 73.8567, | ||
| "visit_notes": "Recovery test", | ||
| "geofence_radius_meters": 100.0 | ||
| } | ||
| resp = client.post("/api/field-officer/check-in", json=checkin_next) | ||
| assert resp.status_code == 200 | ||
|
|
||
| v_next = db.query(FieldOfficerVisit).order_by(FieldOfficerVisit.id.desc()).first() | ||
| assert v_next.previous_visit_hash == last_hash | ||
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: Changing datetime serialization in the hash function breaks verification of existing stored hashes created with the old format.
Prompt for AI agents