-
Notifications
You must be signed in to change notification settings - Fork 35
⚡ Bolt: Implement Blockchain Integrity for Resolution Proofs #618
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
7ebc338
ac27e45
6a0c090
69cfbe5
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 | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -269,6 +269,9 @@ class VerificationStatus(enum.Enum): | |||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| class ResolutionEvidence(Base): | ||||||||||||||||||||||||||||
| __tablename__ = "resolution_evidence" | ||||||||||||||||||||||||||||
| __table_args__ = ( | ||||||||||||||||||||||||||||
| Index("ix_resolution_evidence_grievance_id", "grievance_id"), | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| id = Column(Integer, primary_key=True, index=True) | ||||||||||||||||||||||||||||
| grievance_id = Column(Integer, ForeignKey("grievances.id"), nullable=False) | ||||||||||||||||||||||||||||
| token_id = Column(Integer, ForeignKey("resolution_proof_tokens.id"), nullable=True) | ||||||||||||||||||||||||||||
|
|
@@ -286,6 +289,9 @@ class ResolutionEvidence(Base): | |||||||||||||||||||||||||||
| metadata_bundle = Column(JSON, nullable=True) | ||||||||||||||||||||||||||||
| server_signature = Column(String, nullable=True) | ||||||||||||||||||||||||||||
| verification_status = Column(Enum(VerificationStatus), default=VerificationStatus.PENDING) | ||||||||||||||||||||||||||||
| # Blockchain integrity fields | ||||||||||||||||||||||||||||
| integrity_hash = Column(String, nullable=True) | ||||||||||||||||||||||||||||
| previous_integrity_hash = Column(String, nullable=True, index=True) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
| @property | |
| def created_at(self) -> datetime.datetime | None: | |
| """ | |
| Alias for uploaded_at to satisfy API/schema expectations. | |
| This avoids changing the DB schema while exposing created_at. | |
| """ | |
| return self.uploaded_at | |
| @created_at.setter | |
| def created_at(self, value: datetime.datetime | None) -> None: | |
| self.uploaded_at = value |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |||
| EvidenceAuditLog, VerificationStatus, GrievanceStatus | ||||
| ) | ||||
| from backend.config import get_config | ||||
| from backend.cache import resolution_last_hash_cache | ||||
|
|
||||
| logger = logging.getLogger(__name__) | ||||
|
|
||||
|
|
@@ -172,6 +173,10 @@ def generate_proof_token( | |||
| now = datetime.now(timezone.utc) | ||||
| valid_until = now + timedelta(minutes=TOKEN_VALIDITY_MINUTES) | ||||
|
|
||||
| # Use fixed format for deterministic hashing | ||||
| valid_from_str = now.strftime('%Y-%m-%dT%H:%M:%S') | ||||
| valid_until_str = valid_until.strftime('%Y-%m-%dT%H:%M:%S') | ||||
|
|
||||
| # Build signing payload | ||||
| payload = json.dumps({ | ||||
| "token_id": token_uuid, | ||||
|
|
@@ -180,15 +185,17 @@ def generate_proof_token( | |||
| "geofence_lat": grievance.latitude, | ||||
| "geofence_lon": grievance.longitude, | ||||
| "geofence_radius": geofence_radius, | ||||
| "valid_from": now.isoformat(), | ||||
| "valid_until": valid_until.isoformat(), | ||||
| "valid_from": valid_from_str, | ||||
| "valid_until": valid_until_str, | ||||
| "nonce": nonce | ||||
| }, sort_keys=True) | ||||
|
|
||||
| signature = ResolutionProofService._sign_payload(payload) | ||||
|
|
||||
| # Create token record | ||||
| # Populate both 'token' and 'token_id' for compatibility and to fix AttributeErrors | ||||
| token = ResolutionProofToken( | ||||
| token=token_uuid, | ||||
| token_id=token_uuid, | ||||
| grievance_id=grievance_id, | ||||
| authority_email=authority_email, | ||||
|
|
@@ -197,6 +204,7 @@ def generate_proof_token( | |||
| geofence_radius_meters=geofence_radius, | ||||
| valid_from=now, | ||||
| valid_until=valid_until, | ||||
| expires_at=valid_until, # Required field | ||||
| nonce=nonce, | ||||
| token_signature=signature, | ||||
| is_used=False, | ||||
|
|
@@ -234,8 +242,9 @@ def validate_token(token_id: str, db: Session) -> ResolutionProofToken: | |||
| Raises: | ||||
| ValueError: If any validation check fails | ||||
| """ | ||||
| # Fix: Query against 'token' column as it's the primary/unique identifier in some versions | ||||
| token = db.query(ResolutionProofToken).filter( | ||||
| ResolutionProofToken.token_id == token_id | ||||
| ResolutionProofToken.token == token_id | ||||
| ).first() | ||||
|
Comment on lines
+245
to
248
|
||||
|
|
||||
| if not token: | ||||
|
|
@@ -261,15 +270,19 @@ def validate_token(token_id: str, db: Session) -> ResolutionProofToken: | |||
| if valid_from.tzinfo is None: | ||||
| valid_from = valid_from.replace(tzinfo=timezone.utc) | ||||
|
|
||||
| # Use fixed format for deterministic hashing | ||||
| valid_from_str = valid_from.strftime('%Y-%m-%dT%H:%M:%S') | ||||
| valid_until_str = valid_until.strftime('%Y-%m-%dT%H:%M:%S') | ||||
|
|
||||
| payload = json.dumps({ | ||||
| "token_id": token.token_id, | ||||
| "grievance_id": token.grievance_id, | ||||
| "authority_email": token.authority_email, | ||||
| "geofence_lat": token.geofence_latitude, | ||||
| "geofence_lon": token.geofence_longitude, | ||||
| "geofence_radius": token.geofence_radius_meters, | ||||
| "valid_from": valid_from.isoformat(), | ||||
| "valid_until": valid_until.isoformat(), | ||||
| "valid_from": valid_from_str, | ||||
| "valid_until": valid_until_str, | ||||
| "nonce": token.nonce | ||||
| }, sort_keys=True) | ||||
|
|
||||
|
|
@@ -352,23 +365,40 @@ def submit_evidence( | |||
| f"for grievance(s): {dup_ids}. Possible fraud." | ||||
| ) | ||||
|
|
||||
| # 5. Create server-side signed metadata bundle | ||||
| # 5. Blockchain chaining logic | ||||
| # Performance Boost: Use thread-safe cache to eliminate DB query for last hash | ||||
| prev_hash = resolution_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: The blockchain chaining logic has a race condition under concurrent submissions. Two simultaneous Consider serializing chain appends with a database-level lock (e.g., Prompt for AI agents |
||||
| if prev_hash is None: | ||||
| # Cache miss: Fetch only the last hash from DB | ||||
|
Comment on lines
+368
to
+372
|
||||
| prev_evidence = db.query(ResolutionEvidence.integrity_hash).order_by(ResolutionEvidence.id.desc()).first() | ||||
| prev_hash = prev_evidence[0] if prev_evidence and prev_evidence[0] else "" | ||||
| resolution_last_hash_cache.set(data=prev_hash, key="last_hash") | ||||
|
|
||||
| # Chaining: hash(token_id|evidence_hash|gps|ts|prev_hash) | ||||
| # Use fixed timestamp format for deterministic hashing | ||||
| cap_ts_str = cap_ts.strftime('%Y-%m-%dT%H:%M:%S') | ||||
| chain_content = f"{token.token_id}|{evidence_hash}|{gps_latitude}|{gps_longitude}|{cap_ts_str}|{prev_hash}" | ||||
| integrity_hash = hashlib.sha256(chain_content.encode()).hexdigest() | ||||
|
|
||||
| # 6. Create server-side signed metadata bundle | ||||
| metadata_bundle = { | ||||
| "token_id": token.token_id, | ||||
| "grievance_id": token.grievance_id, | ||||
| "authority_email": token.authority_email, | ||||
| "evidence_hash": evidence_hash, | ||||
| "gps_latitude": gps_latitude, | ||||
| "gps_longitude": gps_longitude, | ||||
| "capture_timestamp": cap_ts.isoformat(), | ||||
| "capture_timestamp": cap_ts_str, | ||||
| "device_fingerprint_hash": device_fingerprint_hash, | ||||
| "geofence_distance_meters": distance, | ||||
| "integrity_hash": integrity_hash, | ||||
| "previous_integrity_hash": prev_hash | ||||
| } | ||||
|
|
||||
| bundle_str = json.dumps(metadata_bundle, sort_keys=True) | ||||
| server_signature = ResolutionProofService._sign_payload(bundle_str) | ||||
|
|
||||
| # 6. Create evidence record | ||||
| # 7. Create evidence record | ||||
| evidence = ResolutionEvidence( | ||||
| grievance_id=token.grievance_id, | ||||
| token_id=token.id, | ||||
|
|
@@ -380,18 +410,23 @@ def submit_evidence( | |||
| metadata_bundle=metadata_bundle, | ||||
| server_signature=server_signature, | ||||
| verification_status=VerificationStatus.VERIFIED, | ||||
| integrity_hash=integrity_hash, | ||||
| previous_integrity_hash=prev_hash | ||||
| ) | ||||
|
|
||||
| db.add(evidence) | ||||
|
|
||||
| # 7. Mark token as used | ||||
| # 8. Mark token as used | ||||
| token.is_used = True | ||||
| token.used_at = datetime.now(timezone.utc) | ||||
|
|
||||
| db.commit() | ||||
| db.refresh(evidence) | ||||
|
|
||||
| # 8. Create audit log | ||||
| # Update cache for next submission AFTER successful commit | ||||
| resolution_last_hash_cache.set(data=integrity_hash, key="last_hash") | ||||
|
|
||||
| # 9. Create audit log | ||||
| ResolutionProofService._create_audit_log( | ||||
| evidence_id=evidence.id, | ||||
| action="created", | ||||
|
|
@@ -422,6 +457,53 @@ def submit_evidence( | |||
| # Verification | ||||
| # ────────────────────────────────────────────── | ||||
|
|
||||
| @staticmethod | ||||
| def verify_evidence_integrity(evidence_id: int, db: Session) -> Dict[str, Any]: | ||||
| """ | ||||
| Verify the cryptographic integrity of a single resolution evidence record. | ||||
| Optimized: Uses previous_integrity_hash column for O(1) verification. | ||||
| """ | ||||
| evidence = db.query(ResolutionEvidence).filter(ResolutionEvidence.id == evidence_id).first() | ||||
| if not evidence: | ||||
| raise ValueError(f"Evidence {evidence_id} not found") | ||||
|
|
||||
| # Determine previous hash (O(1) from stored column) | ||||
| prev_hash = evidence.previous_integrity_hash or "" | ||||
|
|
||||
| # Re-verify the server signature | ||||
| bundle_str = json.dumps(evidence.metadata_bundle, sort_keys=True) | ||||
| signature_valid = ResolutionProofService._verify_signature( | ||||
| bundle_str, evidence.server_signature | ||||
| ) | ||||
|
|
||||
| # Recompute chaining hash | ||||
| cap_ts = evidence.capture_timestamp | ||||
| if cap_ts.tzinfo is None: | ||||
| cap_ts = cap_ts.replace(tzinfo=timezone.utc) | ||||
| cap_ts_str = cap_ts.strftime('%Y-%m-%dT%H:%M:%S') | ||||
|
|
||||
| # Fix: Query token explicitly using the ID stored in evidence | ||||
| token = db.query(ResolutionProofToken.token_id).filter(ResolutionProofToken.id == evidence.token_id).first() | ||||
| token_id_val = token[0] if token else "" | ||||
|
|
||||
| chain_content = f"{token_id_val}|{evidence.evidence_hash}|{evidence.gps_latitude}|{evidence.gps_longitude}|{cap_ts_str}|{prev_hash}" | ||||
| computed_hash = hashlib.sha256(chain_content.encode()).hexdigest() | ||||
|
|
||||
| is_valid = (computed_hash == evidence.integrity_hash) and signature_valid | ||||
|
|
||||
| if is_valid: | ||||
| message = "Integrity verified. This resolution evidence is cryptographically sealed and part of a secure chain." | ||||
| else: | ||||
| message = "Integrity check failed! The evidence data does not match its cryptographic seal." | ||||
|
|
||||
| return { | ||||
| "is_valid": is_valid, | ||||
| "current_hash": evidence.integrity_hash, | ||||
| "computed_hash": computed_hash, | ||||
| "signature_valid": signature_valid, | ||||
|
||||
| "signature_valid": signature_valid, |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ | |
| GenerateRPTRequest, RPTResponse, | ||
| SubmitEvidenceRequest, EvidenceResponse, | ||
| VerificationResponse, AuditTrailResponse, | ||
| DuplicateCheckResponse, | ||
| DuplicateCheckResponse, ResolutionBlockchainVerificationResponse | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
@@ -145,6 +145,25 @@ def verify_resolution( | |
| raise HTTPException(status_code=500, detail="Failed to verify resolution") | ||
|
|
||
|
|
||
| @router.get("/{evidence_id}/blockchain-verify", response_model=ResolutionBlockchainVerificationResponse) | ||
| def verify_evidence_blockchain( | ||
| evidence_id: int, | ||
| db: Session = Depends(get_db) | ||
| ): | ||
|
Comment on lines
+148
to
+152
|
||
| """ | ||
| Verify the cryptographic integrity of a single resolution evidence record using blockchain-style chaining. | ||
| Optimized: Uses previous_integrity_hash column for O(1) verification. | ||
| """ | ||
| try: | ||
| result = ResolutionProofService.verify_evidence_integrity(evidence_id, db) | ||
| return ResolutionBlockchainVerificationResponse(**result) | ||
| except ValueError as e: | ||
| raise HTTPException(status_code=404, detail=str(e)) | ||
| except Exception as e: | ||
| logger.error(f"Error verifying evidence blockchain for {evidence_id}: {e}", exc_info=True) | ||
| raise HTTPException(status_code=500, detail="Failed to verify resolution integrity") | ||
|
|
||
|
|
||
| # ============================================================================ | ||
| # EVIDENCE RETRIEVAL | ||
| # ============================================================================ | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -345,10 +345,19 @@ class EvidenceResponse(BaseModel): | |||||||
| capture_timestamp: datetime = Field(..., description="When evidence was captured") | ||||||||
| verification_status: str = Field(..., description="Verification status: pending, verified, flagged, fraud_detected") | ||||||||
| server_signature: str = Field(..., description="Server cryptographic signature") | ||||||||
| integrity_hash: Optional[str] = Field(None, description="Blockchain integrity hash") | ||||||||
| previous_integrity_hash: Optional[str] = Field(None, description="Previous blockchain hash") | ||||||||
| created_at: datetime = Field(..., description="Record creation timestamp") | ||||||||
| message: str = Field(..., description="Status message") | ||||||||
|
|
||||||||
|
|
||||||||
| class ResolutionBlockchainVerificationResponse(BaseModel): | ||||||||
| is_valid: bool = Field(..., description="Whether the resolution integrity is intact") | ||||||||
|
||||||||
| is_valid: bool = Field(..., description="Whether the resolution integrity is intact") | |
| is_valid: bool = Field(..., description="Whether the resolution integrity is intact") | |
| signature_valid: bool = Field(..., description="Whether the cryptographic signature verification succeeded") |
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 migration uses
DATETIMEin raw SQL (ALTER TABLE ... ADD COLUMN valid_from DATETIME), which is not a valid type in PostgreSQL (Render can run withDATABASE_URL). UseTIMESTAMP/TIMESTAMPTZ(or a SQLAlchemy-compiled type) to keep migrations portable across SQLite/Postgres.