quantumflow-sdk 0.2.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,565 @@
1
+ """
2
+ QChat API Routes - Quantum-Secure Messaging
3
+
4
+ P2P encrypted messaging using QuantumFlow as relay.
5
+ Backend only sees encrypted blobs - cannot decrypt.
6
+ """
7
+
8
+ from fastapi import APIRouter, HTTPException, Depends, WebSocket, WebSocketDisconnect
9
+ from pydantic import BaseModel, Field
10
+ from typing import Optional, List
11
+ from datetime import datetime, timedelta
12
+ import uuid
13
+ import hashlib
14
+ import asyncio
15
+ from collections import defaultdict
16
+
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+ from sqlalchemy import select, or_, and_
19
+ from sqlalchemy.orm import selectinload
20
+
21
+ from db.database import get_async_db
22
+ from db.models import ChatUser, QuantumChannel, ChatMessage, ChannelStatus, MessageStatus
23
+
24
+ router = APIRouter(prefix="/chat", tags=["QChat"])
25
+
26
+ # ==================== WebSocket Connection Manager ====================
27
+
28
+ class ConnectionManager:
29
+ """Manage WebSocket connections for real-time messaging."""
30
+
31
+ def __init__(self):
32
+ # user_id -> WebSocket
33
+ self.active_connections: dict[str, WebSocket] = {}
34
+ # user_id -> list of pending messages
35
+ self.pending_messages: dict[str, list] = defaultdict(list)
36
+
37
+ async def connect(self, user_id: str, websocket: WebSocket):
38
+ await websocket.accept()
39
+ self.active_connections[user_id] = websocket
40
+
41
+ # Send any pending messages
42
+ if user_id in self.pending_messages:
43
+ for msg in self.pending_messages[user_id]:
44
+ await websocket.send_json(msg)
45
+ del self.pending_messages[user_id]
46
+
47
+ def disconnect(self, user_id: str):
48
+ if user_id in self.active_connections:
49
+ del self.active_connections[user_id]
50
+
51
+ async def send_to_user(self, user_id: str, message: dict):
52
+ if user_id in self.active_connections:
53
+ await self.active_connections[user_id].send_json(message)
54
+ return True
55
+ else:
56
+ # Store for later delivery
57
+ self.pending_messages[user_id].append(message)
58
+ return False
59
+
60
+ def is_online(self, user_id: str) -> bool:
61
+ return user_id in self.active_connections
62
+
63
+
64
+ manager = ConnectionManager()
65
+
66
+
67
+ # ==================== Request/Response Models ====================
68
+
69
+ class RegisterUserRequest(BaseModel):
70
+ phone_number: str = Field(..., description="Phone number in E.164 format")
71
+ display_name: Optional[str] = None
72
+ firebase_uid: Optional[str] = None
73
+ device_token: Optional[str] = None
74
+ platform: Optional[str] = None
75
+
76
+
77
+ class RegisterUserResponse(BaseModel):
78
+ user_id: str
79
+ phone_number: str
80
+ display_name: Optional[str]
81
+ created_at: datetime
82
+
83
+
84
+ class EstablishChannelRequest(BaseModel):
85
+ recipient_phone: str = Field(..., description="Recipient phone number")
86
+ sender_id: str = Field(..., description="Sender user ID")
87
+
88
+
89
+ class ChannelResponse(BaseModel):
90
+ channel_id: str
91
+ status: str
92
+ bell_pairs_remaining: int
93
+ error_rate: float
94
+ created_at: datetime
95
+ established_at: Optional[datetime]
96
+
97
+
98
+ class SendMessageRequest(BaseModel):
99
+ channel_id: str
100
+ sender_id: str
101
+ encrypted_content: str = Field(..., description="Encrypted message (base64)")
102
+ content_hash: str = Field(..., description="SHA-256 hash of plaintext")
103
+ compression_ratio: Optional[float] = None
104
+ bell_pair_id: Optional[str] = None
105
+ teleport_fidelity: Optional[float] = None
106
+
107
+
108
+ class MessageResponse(BaseModel):
109
+ message_id: str
110
+ channel_id: str
111
+ sender_id: str
112
+ encrypted_content: str
113
+ status: str
114
+ compression_ratio: Optional[float]
115
+ eavesdrop_detected: bool
116
+ created_at: datetime
117
+
118
+
119
+ class GetMessagesRequest(BaseModel):
120
+ channel_id: str
121
+ user_id: str
122
+ limit: int = 50
123
+ before_id: Optional[str] = None
124
+
125
+
126
+ # ==================== User Registration ====================
127
+
128
+ @router.post("/register", response_model=RegisterUserResponse)
129
+ async def register_user(
130
+ request: RegisterUserRequest,
131
+ db: AsyncSession = Depends(get_async_db)
132
+ ):
133
+ """Register a new QChat user by phone number."""
134
+
135
+ # Check if user exists
136
+ result = await db.execute(
137
+ select(ChatUser).where(ChatUser.phone_number == request.phone_number)
138
+ )
139
+ existing = result.scalar_one_or_none()
140
+
141
+ if existing:
142
+ # Update existing user
143
+ existing.display_name = request.display_name or existing.display_name
144
+ existing.firebase_uid = request.firebase_uid or existing.firebase_uid
145
+ existing.device_token = request.device_token or existing.device_token
146
+ existing.platform = request.platform or existing.platform
147
+ existing.updated_at = datetime.utcnow()
148
+ await db.commit()
149
+
150
+ return RegisterUserResponse(
151
+ user_id=str(existing.id),
152
+ phone_number=existing.phone_number,
153
+ display_name=existing.display_name,
154
+ created_at=existing.created_at
155
+ )
156
+
157
+ # Create new user
158
+ user = ChatUser(
159
+ phone_number=request.phone_number,
160
+ display_name=request.display_name,
161
+ firebase_uid=request.firebase_uid,
162
+ device_token=request.device_token,
163
+ platform=request.platform
164
+ )
165
+ db.add(user)
166
+ await db.commit()
167
+ await db.refresh(user)
168
+
169
+ return RegisterUserResponse(
170
+ user_id=str(user.id),
171
+ phone_number=user.phone_number,
172
+ display_name=user.display_name,
173
+ created_at=user.created_at
174
+ )
175
+
176
+
177
+ @router.get("/user/{phone_number}")
178
+ async def get_user_by_phone(
179
+ phone_number: str,
180
+ db: AsyncSession = Depends(get_async_db)
181
+ ):
182
+ """Look up user by phone number."""
183
+
184
+ result = await db.execute(
185
+ select(ChatUser).where(ChatUser.phone_number == phone_number)
186
+ )
187
+ user = result.scalar_one_or_none()
188
+
189
+ if not user:
190
+ raise HTTPException(status_code=404, detail="User not found")
191
+
192
+ return {
193
+ "user_id": str(user.id),
194
+ "phone_number": user.phone_number,
195
+ "display_name": user.display_name,
196
+ "is_online": manager.is_online(str(user.id))
197
+ }
198
+
199
+
200
+ # ==================== Channel Management ====================
201
+
202
+ @router.post("/channel/establish", response_model=ChannelResponse)
203
+ async def establish_channel(
204
+ request: EstablishChannelRequest,
205
+ db: AsyncSession = Depends(get_async_db)
206
+ ):
207
+ """Establish a quantum-secure channel with another user."""
208
+
209
+ # Get sender
210
+ sender_result = await db.execute(
211
+ select(ChatUser).where(ChatUser.id == uuid.UUID(request.sender_id))
212
+ )
213
+ sender = sender_result.scalar_one_or_none()
214
+ if not sender:
215
+ raise HTTPException(status_code=404, detail="Sender not found")
216
+
217
+ # Get recipient by phone
218
+ recipient_result = await db.execute(
219
+ select(ChatUser).where(ChatUser.phone_number == request.recipient_phone)
220
+ )
221
+ recipient = recipient_result.scalar_one_or_none()
222
+ if not recipient:
223
+ raise HTTPException(status_code=404, detail="Recipient not found")
224
+
225
+ # Check for existing channel
226
+ channel_result = await db.execute(
227
+ select(QuantumChannel).where(
228
+ or_(
229
+ and_(
230
+ QuantumChannel.user_a_id == sender.id,
231
+ QuantumChannel.user_b_id == recipient.id
232
+ ),
233
+ and_(
234
+ QuantumChannel.user_a_id == recipient.id,
235
+ QuantumChannel.user_b_id == sender.id
236
+ )
237
+ )
238
+ )
239
+ )
240
+ existing_channel = channel_result.scalar_one_or_none()
241
+
242
+ if existing_channel:
243
+ return ChannelResponse(
244
+ channel_id=str(existing_channel.id),
245
+ status=existing_channel.status.value,
246
+ bell_pairs_remaining=existing_channel.bell_pairs_remaining,
247
+ error_rate=existing_channel.error_rate,
248
+ created_at=existing_channel.created_at,
249
+ established_at=existing_channel.established_at
250
+ )
251
+
252
+ # Create new channel
253
+ channel = QuantumChannel(
254
+ user_a_id=sender.id,
255
+ user_b_id=recipient.id,
256
+ status=ChannelStatus.ESTABLISHING,
257
+ qkd_key_id=str(uuid.uuid4()),
258
+ bell_pairs_remaining=1000,
259
+ key_generated_at=datetime.utcnow(),
260
+ key_expires_at=datetime.utcnow() + timedelta(days=7)
261
+ )
262
+ db.add(channel)
263
+
264
+ # Simulate QKD exchange (in production, call actual QKD API)
265
+ channel.status = ChannelStatus.READY
266
+ channel.established_at = datetime.utcnow()
267
+
268
+ await db.commit()
269
+ await db.refresh(channel)
270
+
271
+ # Notify recipient
272
+ await manager.send_to_user(str(recipient.id), {
273
+ "type": "channel_established",
274
+ "channel_id": str(channel.id),
275
+ "from_user": sender.display_name or sender.phone_number
276
+ })
277
+
278
+ return ChannelResponse(
279
+ channel_id=str(channel.id),
280
+ status=channel.status.value,
281
+ bell_pairs_remaining=channel.bell_pairs_remaining,
282
+ error_rate=channel.error_rate,
283
+ created_at=channel.created_at,
284
+ established_at=channel.established_at
285
+ )
286
+
287
+
288
+ @router.get("/channels/{user_id}")
289
+ async def get_user_channels(
290
+ user_id: str,
291
+ db: AsyncSession = Depends(get_async_db)
292
+ ):
293
+ """Get all channels for a user."""
294
+
295
+ uid = uuid.UUID(user_id)
296
+ result = await db.execute(
297
+ select(QuantumChannel)
298
+ .where(
299
+ or_(
300
+ QuantumChannel.user_a_id == uid,
301
+ QuantumChannel.user_b_id == uid
302
+ )
303
+ )
304
+ .options(selectinload(QuantumChannel.user_a), selectinload(QuantumChannel.user_b))
305
+ )
306
+ channels = result.scalars().all()
307
+
308
+ return [
309
+ {
310
+ "channel_id": str(ch.id),
311
+ "status": ch.status.value,
312
+ "bell_pairs_remaining": ch.bell_pairs_remaining,
313
+ "other_user": {
314
+ "id": str(ch.user_b.id if str(ch.user_a_id) == user_id else ch.user_a.id),
315
+ "phone": ch.user_b.phone_number if str(ch.user_a_id) == user_id else ch.user_a.phone_number,
316
+ "name": ch.user_b.display_name if str(ch.user_a_id) == user_id else ch.user_a.display_name
317
+ },
318
+ "last_message_at": ch.last_message_at
319
+ }
320
+ for ch in channels
321
+ ]
322
+
323
+
324
+ # ==================== Messaging ====================
325
+
326
+ @router.post("/message/send", response_model=MessageResponse)
327
+ async def send_message(
328
+ request: SendMessageRequest,
329
+ db: AsyncSession = Depends(get_async_db)
330
+ ):
331
+ """Send an encrypted message through a quantum channel."""
332
+
333
+ # Get channel
334
+ channel_result = await db.execute(
335
+ select(QuantumChannel).where(QuantumChannel.id == uuid.UUID(request.channel_id))
336
+ )
337
+ channel = channel_result.scalar_one_or_none()
338
+ if not channel:
339
+ raise HTTPException(status_code=404, detail="Channel not found")
340
+
341
+ if channel.status != ChannelStatus.READY:
342
+ raise HTTPException(status_code=400, detail=f"Channel not ready: {channel.status.value}")
343
+
344
+ # Verify sender is in channel
345
+ sender_uuid = uuid.UUID(request.sender_id)
346
+ if sender_uuid not in [channel.user_a_id, channel.user_b_id]:
347
+ raise HTTPException(status_code=403, detail="Sender not in channel")
348
+
349
+ # Determine recipient
350
+ recipient_id = channel.user_b_id if sender_uuid == channel.user_a_id else channel.user_a_id
351
+
352
+ # Consume bell pair
353
+ if channel.bell_pairs_remaining > 0:
354
+ channel.bell_pairs_remaining -= 1
355
+ else:
356
+ raise HTTPException(status_code=400, detail="No bell pairs remaining. Regenerate keys.")
357
+
358
+ # Simulate eavesdrop detection (random ~2% chance for demo)
359
+ import random
360
+ eavesdrop_detected = random.random() < 0.02
361
+
362
+ if eavesdrop_detected:
363
+ channel.eavesdrop_detected_count += 1
364
+ channel.last_eavesdrop_at = datetime.utcnow()
365
+ channel.error_rate = min(0.25, channel.error_rate + 0.05)
366
+
367
+ if channel.error_rate >= 0.25:
368
+ channel.status = ChannelStatus.COMPROMISED
369
+
370
+ # Create message
371
+ message = ChatMessage(
372
+ channel_id=channel.id,
373
+ sender_id=sender_uuid,
374
+ encrypted_content=request.encrypted_content,
375
+ content_hash=request.content_hash,
376
+ compression_ratio=request.compression_ratio,
377
+ bell_pair_id=request.bell_pair_id,
378
+ teleport_fidelity=request.teleport_fidelity,
379
+ eavesdrop_detected=eavesdrop_detected,
380
+ status=MessageStatus.SENT,
381
+ sent_at=datetime.utcnow()
382
+ )
383
+ db.add(message)
384
+
385
+ channel.last_message_at = datetime.utcnow()
386
+
387
+ await db.commit()
388
+ await db.refresh(message)
389
+
390
+ # Send to recipient via WebSocket
391
+ delivered = await manager.send_to_user(str(recipient_id), {
392
+ "type": "new_message",
393
+ "message_id": str(message.id),
394
+ "channel_id": str(channel.id),
395
+ "sender_id": request.sender_id,
396
+ "encrypted_content": request.encrypted_content,
397
+ "content_hash": request.content_hash,
398
+ "compression_ratio": request.compression_ratio,
399
+ "eavesdrop_detected": eavesdrop_detected,
400
+ "created_at": message.created_at.isoformat()
401
+ })
402
+
403
+ if delivered:
404
+ message.status = MessageStatus.DELIVERED
405
+ message.delivered_at = datetime.utcnow()
406
+ await db.commit()
407
+
408
+ return MessageResponse(
409
+ message_id=str(message.id),
410
+ channel_id=str(channel.id),
411
+ sender_id=str(message.sender_id),
412
+ encrypted_content=message.encrypted_content,
413
+ status=message.status.value,
414
+ compression_ratio=message.compression_ratio,
415
+ eavesdrop_detected=message.eavesdrop_detected,
416
+ created_at=message.created_at
417
+ )
418
+
419
+
420
+ @router.get("/messages/{channel_id}")
421
+ async def get_messages(
422
+ channel_id: str,
423
+ user_id: str,
424
+ limit: int = 50,
425
+ db: AsyncSession = Depends(get_async_db)
426
+ ):
427
+ """Get messages from a channel."""
428
+
429
+ # Verify user is in channel
430
+ channel_result = await db.execute(
431
+ select(QuantumChannel).where(QuantumChannel.id == uuid.UUID(channel_id))
432
+ )
433
+ channel = channel_result.scalar_one_or_none()
434
+ if not channel:
435
+ raise HTTPException(status_code=404, detail="Channel not found")
436
+
437
+ user_uuid = uuid.UUID(user_id)
438
+ if user_uuid not in [channel.user_a_id, channel.user_b_id]:
439
+ raise HTTPException(status_code=403, detail="User not in channel")
440
+
441
+ # Get messages
442
+ result = await db.execute(
443
+ select(ChatMessage)
444
+ .where(ChatMessage.channel_id == uuid.UUID(channel_id))
445
+ .order_by(ChatMessage.created_at.desc())
446
+ .limit(limit)
447
+ )
448
+ messages = result.scalars().all()
449
+
450
+ # Mark as read
451
+ for msg in messages:
452
+ if msg.sender_id != user_uuid and msg.status != MessageStatus.READ:
453
+ msg.status = MessageStatus.READ
454
+ msg.read_at = datetime.utcnow()
455
+ await db.commit()
456
+
457
+ return [
458
+ {
459
+ "message_id": str(msg.id),
460
+ "sender_id": str(msg.sender_id),
461
+ "encrypted_content": msg.encrypted_content,
462
+ "content_hash": msg.content_hash,
463
+ "compression_ratio": msg.compression_ratio,
464
+ "eavesdrop_detected": msg.eavesdrop_detected,
465
+ "status": msg.status.value,
466
+ "created_at": msg.created_at.isoformat()
467
+ }
468
+ for msg in reversed(messages)
469
+ ]
470
+
471
+
472
+ @router.post("/channel/{channel_id}/regenerate-keys")
473
+ async def regenerate_keys(
474
+ channel_id: str,
475
+ db: AsyncSession = Depends(get_async_db)
476
+ ):
477
+ """Regenerate QKD keys for a compromised channel."""
478
+
479
+ result = await db.execute(
480
+ select(QuantumChannel).where(QuantumChannel.id == uuid.UUID(channel_id))
481
+ )
482
+ channel = result.scalar_one_or_none()
483
+ if not channel:
484
+ raise HTTPException(status_code=404, detail="Channel not found")
485
+
486
+ # Regenerate keys
487
+ channel.qkd_key_id = str(uuid.uuid4())
488
+ channel.bell_pairs_remaining = 1000
489
+ channel.key_generated_at = datetime.utcnow()
490
+ channel.key_expires_at = datetime.utcnow() + timedelta(days=7)
491
+ channel.status = ChannelStatus.READY
492
+ channel.error_rate = 0.0
493
+
494
+ await db.commit()
495
+
496
+ # Notify both users
497
+ await manager.send_to_user(str(channel.user_a_id), {
498
+ "type": "keys_regenerated",
499
+ "channel_id": channel_id
500
+ })
501
+ await manager.send_to_user(str(channel.user_b_id), {
502
+ "type": "keys_regenerated",
503
+ "channel_id": channel_id
504
+ })
505
+
506
+ return {
507
+ "status": "success",
508
+ "channel_id": channel_id,
509
+ "new_key_id": channel.qkd_key_id,
510
+ "bell_pairs": channel.bell_pairs_remaining
511
+ }
512
+
513
+
514
+ # ==================== WebSocket ====================
515
+
516
+ @router.websocket("/ws/{user_id}")
517
+ async def websocket_endpoint(
518
+ websocket: WebSocket,
519
+ user_id: str,
520
+ db: AsyncSession = Depends(get_async_db)
521
+ ):
522
+ """WebSocket connection for real-time messaging."""
523
+
524
+ await manager.connect(user_id, websocket)
525
+
526
+ # Update user online status
527
+ result = await db.execute(
528
+ select(ChatUser).where(ChatUser.id == uuid.UUID(user_id))
529
+ )
530
+ user = result.scalar_one_or_none()
531
+ if user:
532
+ user.is_online = True
533
+ await db.commit()
534
+
535
+ try:
536
+ while True:
537
+ data = await websocket.receive_json()
538
+
539
+ # Handle different message types
540
+ if data.get("type") == "ping":
541
+ await websocket.send_json({"type": "pong"})
542
+
543
+ elif data.get("type") == "typing":
544
+ # Forward typing indicator
545
+ channel_id = data.get("channel_id")
546
+ result = await db.execute(
547
+ select(QuantumChannel).where(QuantumChannel.id == uuid.UUID(channel_id))
548
+ )
549
+ channel = result.scalar_one_or_none()
550
+ if channel:
551
+ recipient_id = str(channel.user_b_id) if str(channel.user_a_id) == user_id else str(channel.user_a_id)
552
+ await manager.send_to_user(recipient_id, {
553
+ "type": "typing",
554
+ "channel_id": channel_id,
555
+ "user_id": user_id
556
+ })
557
+
558
+ except WebSocketDisconnect:
559
+ manager.disconnect(user_id)
560
+
561
+ # Update user offline status
562
+ if user:
563
+ user.is_online = False
564
+ user.last_seen = datetime.utcnow()
565
+ await db.commit()