mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Compare commits
6 Commits
@@ -71,6 +71,55 @@ class CryptoBackup(Base):
|
|||||||
blob_json = Column(Text, nullable=False)
|
blob_json = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalPreKeyBundle(Base):
|
||||||
|
__tablename__ = "signal_prekey_bundle"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, unique=True)
|
||||||
|
bundle_json = Column(Text, nullable=False) # JSON string of PreKeyBundleData (identity, signed prekey, registration ID)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalPreKey(Base):
|
||||||
|
__tablename__ = "signal_prekey"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||||
|
prekey_id = Column(Integer, nullable=False) # The prekey ID from the client
|
||||||
|
public_key = Column(Text, nullable=False) # Base64 encoded public key
|
||||||
|
used = Column(Boolean, default=False, nullable=False, index=True) # Whether this prekey has been used
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint('user_id', 'prekey_id', name='_user_prekey_uc'),)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalSession(Base):
|
||||||
|
__tablename__ = "signal_session"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||||
|
recipient_id = Column(Integer, nullable=False, index=True) # The other user in the session
|
||||||
|
device_id = Column(Integer, default=1, nullable=False) # Device ID (always 1 for now)
|
||||||
|
encrypted_session_data = Column(Text, nullable=False) # Encrypted session record (JSON with salt, iv, ciphertext)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint('user_id', 'recipient_id', 'device_id', name='_user_recipient_device_uc'),)
|
||||||
|
|
||||||
|
|
||||||
|
class SentMessagePlaintext(Base):
|
||||||
|
"""Stores encrypted plaintexts of sent messages for history display"""
|
||||||
|
__tablename__ = "sent_message_plaintext"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||||
|
message_id = Column(Integer, nullable=False, index=True) # DM envelope ID
|
||||||
|
recipient_id = Column(Integer, nullable=False, index=True) # The recipient of the message
|
||||||
|
encrypted_data = Column(Text, nullable=False) # Encrypted plaintext (JSON with salt, iv, ciphertext)
|
||||||
|
created_at = Column(DateTime, default=datetime.now, index=True)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint('user_id', 'message_id', name='_user_message_uc'),)
|
||||||
|
|
||||||
|
|
||||||
class DMEnvelope(Base):
|
class DMEnvelope(Base):
|
||||||
__tablename__ = "dm_envelope"
|
__tablename__ = "dm_envelope"
|
||||||
|
|
||||||
|
|||||||
+399
-1
@@ -10,7 +10,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|||||||
|
|
||||||
from constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession, SignalSession, SentMessagePlaintext
|
||||||
from utils import create_token, get_password_hash, verify_password, get_client_ip
|
from utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||||
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||||
import os
|
import os
|
||||||
@@ -479,6 +479,404 @@ def get_public_key_of(request: Request, user_id: int, current_user: User = Depen
|
|||||||
return {"publicKey": row.public_key_b64 if row else None}
|
return {"publicKey": row.public_key_b64 if row else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crypto/signal/prekey-bundle")
|
||||||
|
@rate_limit_per_ip("10/minute")
|
||||||
|
def upload_prekey_bundle(
|
||||||
|
request: Request,
|
||||||
|
payload: dict,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upload Signal Protocol prekey bundle for the current user"""
|
||||||
|
from models import SignalPreKeyBundle, SignalPreKey
|
||||||
|
import json
|
||||||
|
|
||||||
|
bundle = payload.get("bundle")
|
||||||
|
if not bundle:
|
||||||
|
raise HTTPException(status_code=400, detail="bundle required")
|
||||||
|
|
||||||
|
# Validate bundle structure
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="bundle must be a JSON object")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in bundle:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
|
||||||
|
|
||||||
|
if not isinstance(bundle["signedPreKey"], dict) or "keyId" not in bundle["signedPreKey"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
||||||
|
|
||||||
|
# Store bundle (identity key, signed prekey, registration ID) - without the one-time prekey
|
||||||
|
bundle_without_prekey = {
|
||||||
|
"registrationId": bundle["registrationId"],
|
||||||
|
"identityKey": bundle["identityKey"],
|
||||||
|
"signedPreKey": bundle["signedPreKey"]
|
||||||
|
}
|
||||||
|
bundle_json = json.dumps(bundle_without_prekey)
|
||||||
|
if len(bundle_json) > 50000: # 50KB limit
|
||||||
|
raise HTTPException(status_code=400, detail="Bundle too large")
|
||||||
|
|
||||||
|
# Store or update the bundle
|
||||||
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
||||||
|
if row:
|
||||||
|
row.bundle_json = bundle_json
|
||||||
|
row.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
||||||
|
db.add(row)
|
||||||
|
|
||||||
|
# Store the one-time prekey if provided
|
||||||
|
if "preKey" in bundle and bundle["preKey"]:
|
||||||
|
prekey = bundle["preKey"]
|
||||||
|
if isinstance(prekey, dict) and "keyId" in prekey and "publicKey" in prekey:
|
||||||
|
# Check if this prekey already exists
|
||||||
|
existing = db.query(SignalPreKey).filter(
|
||||||
|
SignalPreKey.user_id == current_user.id,
|
||||||
|
SignalPreKey.prekey_id == prekey["keyId"]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing prekey (mark as unused if it was used)
|
||||||
|
existing.public_key = prekey["publicKey"]
|
||||||
|
existing.used = False
|
||||||
|
existing.created_at = datetime.now()
|
||||||
|
else:
|
||||||
|
# Add new prekey
|
||||||
|
new_prekey = SignalPreKey(
|
||||||
|
user_id=current_user.id,
|
||||||
|
prekey_id=prekey["keyId"],
|
||||||
|
public_key=prekey["publicKey"],
|
||||||
|
used=False
|
||||||
|
)
|
||||||
|
db.add(new_prekey)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crypto/signal/prekeys/bulk")
|
||||||
|
@rate_limit_per_ip("10/minute")
|
||||||
|
def upload_prekeys_bulk(
|
||||||
|
request: Request,
|
||||||
|
payload: dict,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upload multiple Signal Protocol prekeys in one request"""
|
||||||
|
from models import SignalPreKeyBundle, SignalPreKey
|
||||||
|
import json
|
||||||
|
|
||||||
|
base_bundle = payload.get("baseBundle")
|
||||||
|
prekeys = payload.get("prekeys", [])
|
||||||
|
|
||||||
|
if not base_bundle:
|
||||||
|
raise HTTPException(status_code=400, detail="baseBundle required")
|
||||||
|
|
||||||
|
if not isinstance(prekeys, list):
|
||||||
|
raise HTTPException(status_code=400, detail="prekeys must be an array")
|
||||||
|
|
||||||
|
# Validate base bundle structure
|
||||||
|
if not isinstance(base_bundle, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="baseBundle must be a JSON object")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ["registrationId", "identityKey", "signedPreKey"]
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in base_bundle:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Missing required field in baseBundle: {field}")
|
||||||
|
|
||||||
|
if not isinstance(base_bundle["signedPreKey"], dict) or "keyId" not in base_bundle["signedPreKey"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid signedPreKey format")
|
||||||
|
|
||||||
|
# Store or update the base bundle (identity key, signed prekey, registration ID)
|
||||||
|
bundle_without_prekey = {
|
||||||
|
"registrationId": base_bundle["registrationId"],
|
||||||
|
"identityKey": base_bundle["identityKey"],
|
||||||
|
"signedPreKey": base_bundle["signedPreKey"]
|
||||||
|
}
|
||||||
|
bundle_json = json.dumps(bundle_without_prekey)
|
||||||
|
if len(bundle_json) > 50000: # 50KB limit
|
||||||
|
raise HTTPException(status_code=400, detail="Bundle too large")
|
||||||
|
|
||||||
|
# Store or update the bundle
|
||||||
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
||||||
|
if row:
|
||||||
|
row.bundle_json = bundle_json
|
||||||
|
row.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
row = SignalPreKeyBundle(user_id=current_user.id, bundle_json=bundle_json)
|
||||||
|
db.add(row)
|
||||||
|
|
||||||
|
# Store all prekeys
|
||||||
|
for prekey in prekeys:
|
||||||
|
if not isinstance(prekey, dict) or "keyId" not in prekey or "publicKey" not in prekey:
|
||||||
|
continue # Skip invalid prekeys
|
||||||
|
|
||||||
|
# Check if this prekey already exists
|
||||||
|
existing = db.query(SignalPreKey).filter(
|
||||||
|
SignalPreKey.user_id == current_user.id,
|
||||||
|
SignalPreKey.prekey_id == prekey["keyId"]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing prekey (mark as unused if it was used)
|
||||||
|
existing.public_key = prekey["publicKey"]
|
||||||
|
existing.used = False
|
||||||
|
existing.created_at = datetime.now()
|
||||||
|
else:
|
||||||
|
# Add new prekey
|
||||||
|
new_prekey = SignalPreKey(
|
||||||
|
user_id=current_user.id,
|
||||||
|
prekey_id=prekey["keyId"],
|
||||||
|
public_key=prekey["publicKey"],
|
||||||
|
used=False
|
||||||
|
)
|
||||||
|
db.add(new_prekey)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "ok", "uploaded": len(prekeys)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/signal/prekey-bundle")
|
||||||
|
def get_prekey_bundle(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get Signal Protocol prekey bundle for the current user"""
|
||||||
|
from models import SignalPreKeyBundle
|
||||||
|
import json
|
||||||
|
|
||||||
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == current_user.id).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
bundle = json.loads(row.bundle_json)
|
||||||
|
return {"bundle": bundle}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/signal/prekey-bundle/of/{user_id}")
|
||||||
|
@rate_limit_per_ip("100/minute")
|
||||||
|
def get_prekey_bundle_of(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get Signal Protocol prekey bundle for another user with prekey rotation"""
|
||||||
|
from models import SignalPreKeyBundle, SignalPreKey
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Get the base bundle (identity key, signed prekey, registration ID)
|
||||||
|
row = db.query(SignalPreKeyBundle).filter(SignalPreKeyBundle.user_id == user_id).first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Prekey bundle not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
bundle = json.loads(row.bundle_json)
|
||||||
|
|
||||||
|
# Find an unused prekey for this user
|
||||||
|
unused_prekey = db.query(SignalPreKey).filter(
|
||||||
|
SignalPreKey.user_id == user_id,
|
||||||
|
SignalPreKey.used == False
|
||||||
|
).order_by(SignalPreKey.created_at.asc()).first()
|
||||||
|
|
||||||
|
if unused_prekey:
|
||||||
|
# Mark this prekey as used (atomic operation)
|
||||||
|
unused_prekey.used = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Add the prekey to the bundle
|
||||||
|
bundle["preKey"] = {
|
||||||
|
"keyId": unused_prekey.prekey_id,
|
||||||
|
"publicKey": unused_prekey.public_key
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# No unused prekeys available - return bundle without prekey
|
||||||
|
# The client will need to establish a session using the signed prekey only
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"bundle": bundle}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=500, detail="Invalid bundle data")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crypto/signal/sessions")
|
||||||
|
@rate_limit_per_ip("100/minute")
|
||||||
|
def upload_signal_sessions(
|
||||||
|
request: Request,
|
||||||
|
payload: dict,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upload encrypted Signal Protocol sessions for the current user"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sessions = payload.get("sessions")
|
||||||
|
if not isinstance(sessions, list):
|
||||||
|
raise HTTPException(status_code=400, detail="sessions must be a list")
|
||||||
|
|
||||||
|
uploaded_count = 0
|
||||||
|
for session_data in sessions:
|
||||||
|
if not isinstance(session_data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
recipient_id = session_data.get("recipientId")
|
||||||
|
device_id = session_data.get("deviceId", 1)
|
||||||
|
encrypted_data = session_data.get("encryptedData")
|
||||||
|
|
||||||
|
if not recipient_id or not encrypted_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate encrypted_data is valid JSON
|
||||||
|
json.loads(encrypted_data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Store or update session
|
||||||
|
existing = db.query(SignalSession).filter(
|
||||||
|
SignalSession.user_id == current_user.id,
|
||||||
|
SignalSession.recipient_id == recipient_id,
|
||||||
|
SignalSession.device_id == device_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.encrypted_session_data = encrypted_data
|
||||||
|
existing.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
new_session = SignalSession(
|
||||||
|
user_id=current_user.id,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
device_id=device_id,
|
||||||
|
encrypted_session_data=encrypted_data
|
||||||
|
)
|
||||||
|
db.add(new_session)
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "uploaded_count": uploaded_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/signal/sessions")
|
||||||
|
@rate_limit_per_ip("60/minute")
|
||||||
|
def get_signal_sessions(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get all encrypted Signal Protocol sessions for the current user"""
|
||||||
|
sessions = db.query(SignalSession).filter(
|
||||||
|
SignalSession.user_id == current_user.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"recipientId": s.recipient_id,
|
||||||
|
"deviceId": s.device_id,
|
||||||
|
"encryptedData": s.encrypted_session_data,
|
||||||
|
"updatedAt": s.updated_at.isoformat()
|
||||||
|
}
|
||||||
|
for s in sessions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crypto/signal/message-plaintexts")
|
||||||
|
@rate_limit_per_ip("100/minute")
|
||||||
|
def upload_message_plaintexts(
|
||||||
|
request: Request,
|
||||||
|
payload: dict,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Upload encrypted plaintexts of sent messages"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
messages = payload.get("messages")
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
raise HTTPException(status_code=400, detail="messages must be a list")
|
||||||
|
|
||||||
|
uploaded_count = 0
|
||||||
|
for msg_data in messages:
|
||||||
|
if not isinstance(msg_data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
message_id = msg_data.get("messageId")
|
||||||
|
recipient_id = msg_data.get("recipientId")
|
||||||
|
encrypted_data = msg_data.get("encryptedData")
|
||||||
|
|
||||||
|
if not message_id or not recipient_id or not encrypted_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate encrypted_data is valid JSON
|
||||||
|
json.loads(encrypted_data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Store or update plaintext
|
||||||
|
existing = db.query(SentMessagePlaintext).filter(
|
||||||
|
SentMessagePlaintext.user_id == current_user.id,
|
||||||
|
SentMessagePlaintext.message_id == message_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.encrypted_data = encrypted_data
|
||||||
|
else:
|
||||||
|
new_plaintext = SentMessagePlaintext(
|
||||||
|
user_id=current_user.id,
|
||||||
|
message_id=message_id,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
encrypted_data=encrypted_data
|
||||||
|
)
|
||||||
|
db.add(new_plaintext)
|
||||||
|
uploaded_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "uploaded_count": uploaded_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crypto/signal/message-plaintexts")
|
||||||
|
@rate_limit_per_ip("60/minute")
|
||||||
|
def get_message_plaintexts(
|
||||||
|
request: Request,
|
||||||
|
recipient_id: int | None = None, # Optional filter by recipient
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get encrypted plaintexts of sent messages for the current user"""
|
||||||
|
query = db.query(SentMessagePlaintext).filter(
|
||||||
|
SentMessagePlaintext.user_id == current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if recipient_id is not None:
|
||||||
|
query = query.filter(SentMessagePlaintext.recipient_id == recipient_id)
|
||||||
|
|
||||||
|
plaintexts = query.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"messageId": p.message_id,
|
||||||
|
"recipientId": p.recipient_id,
|
||||||
|
"encryptedData": p.encrypted_data,
|
||||||
|
"createdAt": p.created_at.isoformat()
|
||||||
|
}
|
||||||
|
for p in plaintexts
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/search")
|
@router.get("/users/search")
|
||||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
||||||
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export async function deriveAuthSecret(username: string, password: string): Prom
|
|||||||
return b64(derived);
|
return b64(derived);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||||
// Try to restore from backup
|
// Try to restore from backup
|
||||||
const blobJson = await fetchBackupBlob(token);
|
const blobJson = await fetchBackupBlob(token);
|
||||||
|
|||||||
@@ -1,28 +1,148 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { getAuthHeaders } from "../user/auth";
|
import api from "@/core/api";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
import { randomBytes } from "@/utils/crypto/kdf";
|
||||||
import { getCurrentKeys } from "../user/auth";
|
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64 } from "@/utils/utils";
|
||||||
import { fetchUserPublicKey } from "../crypto/identity";
|
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||||
import { fetchUsers, searchUsers } from "../user/search";
|
import { useUserStore } from "@/state/user";
|
||||||
|
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
|
||||||
|
|
||||||
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
export async function decrypt(envelope: DmEnvelope, senderId: number): Promise<string> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
// Obtain the key
|
if (!envelope.ciphertext) {
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
throw new Error("DM envelope missing ciphertext");
|
||||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
}
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
|
||||||
|
|
||||||
// Decrypt
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
|
||||||
return new TextDecoder().decode(msg);
|
// Remove padding (backward compatible with old messages)
|
||||||
|
// Check if ciphertext is base64 (padded) or already JSON (unpadded)
|
||||||
|
let ciphertextStr: string = envelope.ciphertext;
|
||||||
|
|
||||||
|
// Check if it's base64 (padded messages are base64)
|
||||||
|
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
|
||||||
|
|
||||||
|
if (isBase64) {
|
||||||
|
// Try to remove padding
|
||||||
|
try {
|
||||||
|
const unpadded = removePadding(envelope.ciphertext);
|
||||||
|
// Verify it's valid JSON before using it
|
||||||
|
JSON.parse(unpadded);
|
||||||
|
ciphertextStr = unpadded;
|
||||||
|
} catch {
|
||||||
|
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
|
||||||
|
try {
|
||||||
|
JSON.parse(envelope.ciphertext);
|
||||||
|
ciphertextStr = envelope.ciphertext;
|
||||||
|
} catch {
|
||||||
|
// If both fail, throw an error
|
||||||
|
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not base64, assume it's already JSON (unpadded message)
|
||||||
|
ciphertextStr = envelope.ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Signal Protocol message
|
||||||
|
let signalCiphertext: { type: number; body: string };
|
||||||
|
try {
|
||||||
|
signalCiphertext = JSON.parse(ciphertextStr);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse ciphertext as JSON: ${error instanceof Error ? error.message : String(error)}. Ciphertext length: ${ciphertextStr.length}, first 100 chars: ${ciphertextStr.substring(0, 100)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signalCiphertext || typeof signalCiphertext !== "object") {
|
||||||
|
throw new Error("Invalid Signal Protocol message format: not an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof signalCiphertext.type !== "number") {
|
||||||
|
throw new Error("Invalid Signal Protocol message format: type is not a number");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
|
||||||
|
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if body contains non-printable characters (corrupted binary data from old encryption)
|
||||||
|
// This must be checked first, before any base64 validation
|
||||||
|
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
|
||||||
|
if (hasNonPrintable) {
|
||||||
|
// This is a corrupted message from before the base64 conversion fix
|
||||||
|
// It cannot be decrypted - the body contains raw binary data instead of base64
|
||||||
|
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
|
||||||
|
return "_This message is corrupted and cannot be displayed._";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if body contains Unicode escape sequences (from JSON.stringify escaping)
|
||||||
|
// If so, we need to unescape them to get the actual base64 string
|
||||||
|
let bodyToDecode = signalCiphertext.body;
|
||||||
|
|
||||||
|
// Check for literal backslash-u sequences (before JSON parsing, these would be "\\u")
|
||||||
|
// After JSON parsing, Unicode escapes are converted to actual characters, so we check for
|
||||||
|
// the pattern that indicates it might have been escaped
|
||||||
|
if (bodyToDecode.includes("\\u") || bodyToDecode.match(/\\u[0-9a-fA-F]{4}/)) {
|
||||||
|
// Try to unescape Unicode sequences by wrapping in JSON quotes
|
||||||
|
try {
|
||||||
|
bodyToDecode = JSON.parse(`"${bodyToDecode.replace(/\\/g, "\\\\")}"`);
|
||||||
|
} catch {
|
||||||
|
// If unescaping fails, use the original
|
||||||
|
bodyToDecode = signalCiphertext.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that body is valid base64 before attempting decryption
|
||||||
|
// Check if it's a valid base64 string (only contains base64 characters and padding)
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (!base64Regex.test(bodyToDecode)) {
|
||||||
|
// Log for debugging - this should help identify the issue
|
||||||
|
console.error("Invalid base64 in body:", {
|
||||||
|
bodyType: typeof signalCiphertext.body,
|
||||||
|
bodyLength: signalCiphertext.body.length,
|
||||||
|
unescapedLength: bodyToDecode.length,
|
||||||
|
first50: signalCiphertext.body.substring(0, 50),
|
||||||
|
unescapedFirst50: bodyToDecode.substring(0, 50),
|
||||||
|
envelopeId: envelope.id
|
||||||
|
});
|
||||||
|
throw new Error(`Invalid base64 format in ciphertext body`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the unescaped body for decryption
|
||||||
|
signalCiphertext.body = bodyToDecode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to decode a small portion to validate base64
|
||||||
|
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
|
||||||
|
} catch (error) {
|
||||||
|
// Log for debugging
|
||||||
|
console.error("Base64 decode failed:", {
|
||||||
|
bodyLength: signalCiphertext.body.length,
|
||||||
|
first50: signalCiphertext.body.substring(0, 50),
|
||||||
|
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
||||||
|
return plaintext;
|
||||||
|
} catch (error) {
|
||||||
|
// If decryption fails, check if it's a session issue
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
if (errorMessage.includes("No session exists") || errorMessage.includes("No record for device")) {
|
||||||
|
console.warn(`Session missing for sender ${senderId} (envelope ID: ${envelope.id}). This may happen after page reload if the session was not properly restored.`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||||
@@ -31,35 +151,106 @@ export async function fetchMessages(userId: number, token: string, limit: number
|
|||||||
url += `&before_id=${beforeId}`;
|
url += `&before_id=${beforeId}`;
|
||||||
}
|
}
|
||||||
const response = await globalThis.fetch(url, {
|
const response = await globalThis.fetch(url, {
|
||||||
headers: getAuthHeaders(token, true)
|
headers: api.user.auth.getAuthHeaders(token, true)
|
||||||
});
|
});
|
||||||
if (!response.ok) return { messages: [], has_more: false };
|
if (!response.ok) return { messages: [], has_more: false };
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
export async function send(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
// Encryption key
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
// Encrypt the message
|
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
let hasSession = false;
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
try {
|
||||||
|
hasSession = await signalService.hasSession(recipientId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to check session, will attempt to establish new one:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSession) {
|
||||||
|
try {
|
||||||
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
} catch (error) {
|
||||||
|
// Re-throw PrekeyExhaustedError as-is for proper handling
|
||||||
|
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Log other errors for debugging
|
||||||
|
console.error("Failed to establish session:", {
|
||||||
|
recipientId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
// Re-throw other errors
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with Signal Protocol
|
||||||
|
let ciphertext: { type: number; body: string };
|
||||||
|
try {
|
||||||
|
ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to encrypt message:", {
|
||||||
|
recipientId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the body is valid base64 before stringifying
|
||||||
|
if (ciphertext.body && typeof ciphertext.body === "string") {
|
||||||
|
try {
|
||||||
|
// Test that body is valid base64
|
||||||
|
atob(ciphertext.body.substring(0, Math.min(4, ciphertext.body.length)));
|
||||||
|
|
||||||
|
// Verify the entire body is valid base64
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (!base64Regex.test(ciphertext.body)) {
|
||||||
|
console.error("Invalid base64 characters in encrypted body:", {
|
||||||
|
bodyLength: ciphertext.body.length,
|
||||||
|
first100: ciphertext.body.substring(0, 100),
|
||||||
|
last100: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 100))
|
||||||
|
});
|
||||||
|
throw new Error("Encrypted body contains invalid base64 characters");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Encrypted body is not valid base64: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stringify the ciphertext - JSON.stringify should not escape base64 strings
|
||||||
|
const ciphertextJson = JSON.stringify(ciphertext);
|
||||||
|
|
||||||
|
// Verify the stringified JSON doesn't have escaped characters in the body field
|
||||||
|
const parsed = JSON.parse(ciphertextJson);
|
||||||
|
if (parsed.body !== ciphertext.body) {
|
||||||
|
console.error("Body was modified during JSON stringification:", {
|
||||||
|
original: ciphertext.body.substring(0, 50),
|
||||||
|
stringified: parsed.body.substring(0, 50),
|
||||||
|
originalLength: ciphertext.body.length,
|
||||||
|
stringifiedLength: parsed.body.length
|
||||||
|
});
|
||||||
|
throw new Error("Body was incorrectly escaped during JSON stringification");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add padding to obfuscate message size (anti-censorship)
|
||||||
|
const paddedCiphertext = addPadding(ciphertextJson);
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
const payload: SendDMRequest = {
|
||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: b64(encMsg.iv),
|
iv: "", // Not used for Signal Protocol
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: paddedCiphertext, // Padded Signal Protocol message
|
||||||
salt: b64(wkSalt),
|
salt: "", // Not used for Signal Protocol
|
||||||
iv2: b64(wrap.iv),
|
iv2: "", // Not used for Signal Protocol
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
wrappedMk: "" // Not used for Signal Protocol
|
||||||
};
|
};
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
if (replyToId) payload.replyToId = replyToId;
|
||||||
|
|
||||||
@@ -71,19 +262,44 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p
|
|||||||
},
|
},
|
||||||
data: payload
|
data: payload
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: We'll cache the message when we receive the dmNew confirmation via WebSocket
|
||||||
|
// which contains the actual message ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
export async function sendWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
|
|
||||||
|
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||||
|
const hasSession = await signalService.hasSession(recipientId);
|
||||||
|
if (!hasSession) {
|
||||||
|
try {
|
||||||
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
} catch (error) {
|
||||||
|
// Re-throw PrekeyExhaustedError as-is for proper handling
|
||||||
|
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Re-throw other errors
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate master key for file encryption
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
// Encrypt the master key using Signal Protocol
|
||||||
|
const mkBase64 = b64(mk);
|
||||||
|
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
||||||
|
|
||||||
|
// Add padding to obfuscate master key size
|
||||||
|
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
@@ -115,30 +331,54 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
|
|||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: b64(encMsg.iv),
|
iv: b64(encMsg.iv),
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: b64(encMsg.ciphertext),
|
||||||
salt: b64(wkSalt),
|
salt: "", // Not used for Signal Protocol
|
||||||
iv2: b64(wrap.iv),
|
iv2: "", // Not used for Signal Protocol
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
|
||||||
} satisfies BaseDmEnvelope));
|
} satisfies BaseDmEnvelope));
|
||||||
|
|
||||||
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
|
await globalThis.fetch(`${API_BASE_URL}/dm/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: getAuthHeaders(token, false),
|
headers: api.user.auth.getAuthHeaders(token, false),
|
||||||
body: form
|
body: form
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
export async function edit(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
|
|
||||||
|
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||||
|
const hasSession = await signalService.hasSession(recipientId);
|
||||||
|
if (!hasSession) {
|
||||||
|
try {
|
||||||
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
} catch (error) {
|
||||||
|
// Re-throw PrekeyExhaustedError as-is for proper handling
|
||||||
|
if (error instanceof api.crypto.prekeys.PrekeyExhaustedError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Re-throw other errors
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate fresh master key for the edited message
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
// Encrypt the master key using Signal Protocol
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
const mkBase64 = b64(mk);
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
||||||
|
|
||||||
|
// Add padding to obfuscate master key size
|
||||||
|
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
||||||
|
|
||||||
|
// Encrypt the message content with the master key
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
await request({
|
await request({
|
||||||
type: "dmEdit",
|
type: "dmEdit",
|
||||||
@@ -147,9 +387,9 @@ export async function edit(id: number, recipientPublicKeyB64: string, newPlainte
|
|||||||
id,
|
id,
|
||||||
iv: b64(encMsg.iv),
|
iv: b64(encMsg.iv),
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: b64(encMsg.ciphertext),
|
||||||
iv2: b64(wrap.iv),
|
iv2: "", // Not used for Signal Protocol
|
||||||
wrappedMk: b64(wrap.ciphertext),
|
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
|
||||||
salt: b64(wkSalt)
|
salt: "" // Not used for Signal Protocol
|
||||||
}
|
}
|
||||||
} as DMEditRequest);
|
} as DMEditRequest);
|
||||||
}
|
}
|
||||||
@@ -170,7 +410,7 @@ export interface ConversationResponse {
|
|||||||
|
|
||||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||||
headers: getAuthHeaders(token, true)
|
headers: api.user.auth.getAuthHeaders(token, true)
|
||||||
});
|
});
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -189,6 +429,7 @@ export async function markRead(id: number, authToken: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
// Re-export user functions for convenience
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
export { fetchUsers, searchUsers } from "@/core/api/users";
|
||||||
|
export { fetchUserPublicKey } from "@/core/api/crypto/identity";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { API_BASE_URL } from "@/core/config";
|
|||||||
import { getAuthHeaders } from "./account";
|
import { getAuthHeaders } from "./account";
|
||||||
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
|
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the current user's public key
|
* Fetches the current user's public key
|
||||||
@@ -74,3 +75,39 @@ export async function uploadBackupBlob(blobJson: string, token: string): Promise
|
|||||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads Signal Protocol prekey bundle for the current user
|
||||||
|
*/
|
||||||
|
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
|
||||||
|
// Re-export from prekeys.ts
|
||||||
|
const { uploadPreKeyBundle: upload } = await import("./crypto/prekeys");
|
||||||
|
return upload(bundle, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads all available prekeys to the server for rotation
|
||||||
|
*/
|
||||||
|
export async function uploadAllPreKeys(
|
||||||
|
baseBundle: Omit<PreKeyBundleData, "preKey">,
|
||||||
|
prekeys: Array<{ keyId: number; publicKey: string }>,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Re-export from prekeys.ts
|
||||||
|
const { uploadAllPreKeys: upload } = await import("./crypto/prekeys");
|
||||||
|
return upload(baseBundle, prekeys, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches Signal Protocol prekey bundle for another user
|
||||||
|
*/
|
||||||
|
export async function fetchPreKeyBundle(userId: number, token: string): Promise<any | null> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data.bundle || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* API functions for managing encrypted message plaintexts on the server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import api from "@/core/api";
|
||||||
|
|
||||||
|
export interface MessagePlaintextData {
|
||||||
|
messageId: number;
|
||||||
|
recipientId: number;
|
||||||
|
encryptedData: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessagePlaintextResponse {
|
||||||
|
messageId: number;
|
||||||
|
recipientId: number;
|
||||||
|
encryptedData: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload encrypted message plaintexts to the server
|
||||||
|
*/
|
||||||
|
export async function uploadMessagePlaintexts(
|
||||||
|
messages: MessagePlaintextData[],
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/crypto/signal/message-plaintexts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...api.user.auth.getAuthHeaders(token, false)
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ messages })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Failed to upload message plaintexts" }));
|
||||||
|
throw new Error(error.detail || "Failed to upload message plaintexts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch encrypted message plaintexts from the server
|
||||||
|
*/
|
||||||
|
export async function fetchMessagePlaintexts(
|
||||||
|
token: string,
|
||||||
|
recipientId?: number
|
||||||
|
): Promise<MessagePlaintextResponse[]> {
|
||||||
|
let url = `${API_BASE_URL}/crypto/signal/message-plaintexts`;
|
||||||
|
if (recipientId !== undefined) {
|
||||||
|
const separator = url.includes("?") ? "&" : "?";
|
||||||
|
url = `${url}${separator}recipient_id=${recipientId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: api.user.auth.getAuthHeaders(token, false)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ detail: "Failed to fetch message plaintexts" }));
|
||||||
|
throw new Error(error.detail || "Failed to fetch message plaintexts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.messages || [];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,14 +1,89 @@
|
|||||||
// Placeholder for Signal Protocol pre-key management
|
import { API_BASE_URL } from "@/core/config";
|
||||||
// Will be implemented when Signal Protocol is added
|
import { getAuthHeaders } from "../user/auth";
|
||||||
|
import type { PreKeyBundleData } from "@/utils/crypto/signalProtocol";
|
||||||
|
|
||||||
export async function upload(_bundle: unknown, _token: string): Promise<void> {
|
/**
|
||||||
// TODO: Implement Signal Protocol pre-key upload
|
* Uploads Signal Protocol prekey bundle for the current user
|
||||||
throw new Error("Not implemented yet");
|
* This uploads the base bundle (identity, signed prekey) and one prekey
|
||||||
|
*/
|
||||||
|
export async function uploadPreKeyBundle(bundle: PreKeyBundleData, token: string): Promise<void> {
|
||||||
|
const payload = { bundle };
|
||||||
|
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to upload prekey bundle");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetch(_userId: number, _token: string): Promise<unknown> {
|
/**
|
||||||
// TODO: Implement Signal Protocol pre-key fetch
|
* Uploads all available prekeys to the server for rotation in a single request
|
||||||
throw new Error("Not implemented yet");
|
*/
|
||||||
|
export async function uploadAllPreKeys(
|
||||||
|
baseBundle: Omit<PreKeyBundleData, "preKey">,
|
||||||
|
prekeys: Array<{ keyId: number; publicKey: string }>,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const payload = {
|
||||||
|
baseBundle,
|
||||||
|
prekeys
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekeys/bulk`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to upload prekeys: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error for prekey exhaustion
|
||||||
|
*/
|
||||||
|
export class PrekeyExhaustedError extends Error {
|
||||||
|
constructor(public readonly recipientId: number) {
|
||||||
|
super("Recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys.");
|
||||||
|
this.name = "PrekeyExhaustedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches Signal Protocol prekey bundle for another user
|
||||||
|
* @throws {PrekeyExhaustedError} If the recipient has no unused prekeys available
|
||||||
|
*/
|
||||||
|
export async function fetchPreKeyBundle(userId: number, token: string): Promise<PreKeyBundleData> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/prekey-bundle/of/${userId}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new Error("Recipient has not set up encryption. They need to log in to initialize their encryption keys.");
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch prekey bundle");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const bundle = data.bundle;
|
||||||
|
|
||||||
|
// Check if bundle exists but has no prekey (all prekeys exhausted)
|
||||||
|
if (!bundle) {
|
||||||
|
throw new PrekeyExhaustedError(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If bundle exists but has no preKey field, it means all prekeys are exhausted
|
||||||
|
// The backend returns bundle without preKey when no unused prekeys are available
|
||||||
|
if (!bundle.preKey) {
|
||||||
|
throw new PrekeyExhaustedError(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* API functions for managing Signal Protocol sessions on the server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/core/config";
|
||||||
|
import { getAuthHeaders } from "../user/auth";
|
||||||
|
|
||||||
|
export interface SessionData {
|
||||||
|
recipientId: number;
|
||||||
|
deviceId: number;
|
||||||
|
encryptedData: string; // JSON string of encrypted session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload encrypted Signal Protocol sessions to the server
|
||||||
|
*/
|
||||||
|
export async function uploadSessions(sessions: SessionData[], token: string): Promise<void> {
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sessions
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to upload sessions: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all encrypted Signal Protocol sessions from the server
|
||||||
|
*/
|
||||||
|
export async function fetchSessions(token: string): Promise<SessionData[]> {
|
||||||
|
console.log("[Session API] Fetching sessions from server...");
|
||||||
|
console.log("[Session API] URL:", `${API_BASE_URL}/crypto/signal/sessions`);
|
||||||
|
|
||||||
|
const headers = getAuthHeaders(token, true);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/crypto/signal/sessions`, {
|
||||||
|
method: "GET",
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Session API] Response status:", res.status, res.statusText);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => "Unknown error");
|
||||||
|
console.error("[Session API] Failed to fetch sessions:", {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
errorText
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to fetch sessions: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log("[Session API] Response data:", {
|
||||||
|
hasSessions: !!data.sessions,
|
||||||
|
sessionCount: data.sessions?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.sessions || [];
|
||||||
|
}
|
||||||
|
|
||||||
+14
-176
@@ -1,178 +1,16 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
// Re-export from dmApi.ts which has Signal Protocol support
|
||||||
import { getAuthHeaders } from "./account";
|
export {
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
decryptDm,
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
fetchDMHistory,
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
sendDMViaWebSocket,
|
||||||
import { getCurrentKeys } from "./account";
|
sendDmWithFiles,
|
||||||
import { request } from "@/core/websocket";
|
editDmEnvelope,
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
deleteDmEnvelope,
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
fetchDMConversations,
|
||||||
import { fetchUserPublicKey } from "./crypto";
|
fetchUsers,
|
||||||
import { fetchUsers, searchUsers } from "./users";
|
searchUsers,
|
||||||
|
fetchUserPublicKey
|
||||||
|
} from "./dmApi";
|
||||||
|
|
||||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
export type { DMConversationResponse } from "./dmApi";
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Obtain the key
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
|
||||||
return new TextDecoder().decode(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const data = await response.json();
|
|
||||||
return data.messages || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
|
||||||
|
|
||||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Encryption key
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
// Encrypt the message
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
};
|
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmSend",
|
|
||||||
credentials: {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: authToken
|
|
||||||
},
|
|
||||||
data: payload
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
const names: string[] = [];
|
|
||||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
|
||||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
// Encrypt file with same mk
|
|
||||||
const data = new Uint8Array(await f.arrayBuffer());
|
|
||||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
|
||||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
|
||||||
const serverName = f.name; // server uses provided name
|
|
||||||
names.push(serverName);
|
|
||||||
form.append("files", new File([blob], serverName));
|
|
||||||
}
|
|
||||||
form.append("fileNames", JSON.stringify(names));
|
|
||||||
|
|
||||||
// Merge files metadata into plaintext JSON and encrypt
|
|
||||||
let obj: DmEncryptedJSON;
|
|
||||||
try {
|
|
||||||
obj = JSON.parse(plaintextJson);
|
|
||||||
} catch {
|
|
||||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
|
||||||
form.append("dm_payload", JSON.stringify({
|
|
||||||
recipientId: recipientId,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
} satisfies BaseDmEnvelope));
|
|
||||||
|
|
||||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: getAuthHeaders(token, false),
|
|
||||||
body: form
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
|
||||||
const keys = getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
await request({
|
|
||||||
type: "dmEdit",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
iv: b64(encMsg.iv),
|
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext),
|
|
||||||
salt: b64(wkSalt)
|
|
||||||
}
|
|
||||||
} as DMEditRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
|
||||||
await request({
|
|
||||||
type: "dmDelete",
|
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
|
||||||
data: { id, recipientId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DMConversationResponse {
|
|
||||||
user: User;
|
|
||||||
lastMessage: DmEnvelope;
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
|
||||||
headers: getAuthHeaders(token, true)
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = await res.json();
|
|
||||||
return data.conversations || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
+200
-63
@@ -1,64 +1,163 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { getAuthHeaders } from "./account";
|
import api from "@/core/api";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { importAesGcmKey, aesGcmEncrypt } from "@/utils/crypto/symmetric";
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
import { randomBytes } from "@/utils/crypto/kdf";
|
||||||
import { getCurrentKeys } from "./account";
|
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64 } from "@/utils/utils";
|
||||||
import { fetchUserPublicKey } from "./crypto";
|
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||||
import { fetchUsers, searchUsers } from "./users";
|
import { useUserStore } from "@/state/user";
|
||||||
|
import { addPadding, removePadding } from "@/utils/crypto/obfuscation";
|
||||||
|
|
||||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
export async function decryptDm(envelope: DmEnvelope, senderId: number): Promise<string> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
// Obtain the key
|
if (!envelope.ciphertext) {
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
throw new Error("DM envelope missing ciphertext");
|
||||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
}
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
|
||||||
|
|
||||||
// Decrypt
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
|
||||||
return new TextDecoder().decode(msg);
|
// Remove padding (backward compatible with old messages)
|
||||||
|
// Check if ciphertext is base64 (padded messages are base64)
|
||||||
|
let ciphertextStr: string = envelope.ciphertext;
|
||||||
|
|
||||||
|
// Check if it's base64 (padded messages are base64)
|
||||||
|
const base64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
const isBase64 = base64Pattern.test(envelope.ciphertext) && envelope.ciphertext.length > 0;
|
||||||
|
|
||||||
|
if (isBase64) {
|
||||||
|
// Try to remove padding
|
||||||
|
try {
|
||||||
|
const unpadded = removePadding(envelope.ciphertext);
|
||||||
|
// Verify it's valid JSON before using it
|
||||||
|
JSON.parse(unpadded);
|
||||||
|
ciphertextStr = unpadded;
|
||||||
|
} catch {
|
||||||
|
// If padding removal fails, try using the base64 directly as JSON (shouldn't happen, but handle gracefully)
|
||||||
|
try {
|
||||||
|
JSON.parse(envelope.ciphertext);
|
||||||
|
ciphertextStr = envelope.ciphertext;
|
||||||
|
} catch {
|
||||||
|
// If both fail, throw an error
|
||||||
|
throw new Error(`Failed to process ciphertext: not valid base64 padded data and not valid JSON. Length: ${envelope.ciphertext.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not base64, assume it's already JSON (unpadded message)
|
||||||
|
ciphertextStr = envelope.ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Signal Protocol message
|
||||||
|
let signalCiphertext: { type: number; body: string };
|
||||||
|
try {
|
||||||
|
signalCiphertext = JSON.parse(ciphertextStr);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse Signal Protocol message: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signalCiphertext || typeof signalCiphertext !== "object") {
|
||||||
|
throw new Error("Invalid Signal Protocol message format: not an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof signalCiphertext.type !== "number") {
|
||||||
|
throw new Error("Invalid Signal Protocol message format: type is not a number");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signalCiphertext.body || typeof signalCiphertext.body !== "string") {
|
||||||
|
throw new Error("Invalid Signal Protocol message format: body is missing or not a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that body is valid base64 before attempting decryption
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (!base64Regex.test(signalCiphertext.body)) {
|
||||||
|
// Check if body contains non-printable characters (corrupted binary data)
|
||||||
|
const hasNonPrintable = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x9F]/.test(signalCiphertext.body);
|
||||||
|
if (hasNonPrintable) {
|
||||||
|
// This is a corrupted message from before the base64 conversion fix
|
||||||
|
// It cannot be decrypted - the body contains raw binary data instead of base64
|
||||||
|
console.warn(`Message corrupted: body contains binary data instead of base64 (envelope ID: ${envelope.id}). This message was encrypted before the encryption fix and cannot be decrypted.`);
|
||||||
|
|
||||||
|
return "_This message is corrupted and cannot be displayed._";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Invalid base64 in body:", {
|
||||||
|
bodyType: typeof signalCiphertext.body,
|
||||||
|
bodyLength: signalCiphertext.body.length,
|
||||||
|
first50: signalCiphertext.body.substring(0, 50),
|
||||||
|
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
||||||
|
envelopeId: envelope.id
|
||||||
|
});
|
||||||
|
throw new Error(`Invalid base64 format in ciphertext body`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to decode a small portion to validate base64
|
||||||
|
atob(signalCiphertext.body.substring(0, Math.min(4, signalCiphertext.body.length)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Base64 decode failed:", {
|
||||||
|
bodyLength: signalCiphertext.body.length,
|
||||||
|
first50: signalCiphertext.body.substring(0, 50),
|
||||||
|
last50: signalCiphertext.body.substring(Math.max(0, signalCiphertext.body.length - 50)),
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw new Error(`Invalid base64 in ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plaintext = await signalService.decryptMessage(senderId, signalCiphertext);
|
||||||
|
return plaintext;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to decrypt DM: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||||
headers: getAuthHeaders(token, true)
|
headers: api.user.auth.getAuthHeaders(token, true)
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.messages || [];
|
return data.messages || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export user functions for convenience
|
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
|
||||||
|
|
||||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
export async function sendDMViaWebSocket(recipientId: number, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
// Encryption key
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
// Encrypt the message
|
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
const hasSession = await signalService.hasSession(recipientId);
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
if (!hasSession) {
|
||||||
|
// Fetch prekey bundle from server
|
||||||
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||||
|
if (!bundle) {
|
||||||
|
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
|
||||||
|
}
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with Signal Protocol
|
||||||
|
const ciphertext = await signalService.encryptMessage(recipientId, plaintext);
|
||||||
|
|
||||||
|
// Add padding to obfuscate message size (anti-censorship)
|
||||||
|
const paddedCiphertext = addPadding(JSON.stringify(ciphertext));
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
const payload: SendDMRequest = {
|
||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: b64(encMsg.iv),
|
iv: "", // Not used for Signal Protocol
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: paddedCiphertext, // Padded Signal Protocol message
|
||||||
salt: b64(wkSalt),
|
salt: "", // Not used for Signal Protocol
|
||||||
iv2: b64(wrap.iv),
|
iv2: "", // Not used for Signal Protocol
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
wrappedMk: "" // Not used for Signal Protocol
|
||||||
};
|
};
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
if (replyToId) payload.replyToId = replyToId;
|
||||||
|
|
||||||
@@ -72,17 +171,33 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
export async function sendDmWithFiles(recipientId: number, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
|
|
||||||
|
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||||
|
const hasSession = await signalService.hasSession(recipientId);
|
||||||
|
if (!hasSession) {
|
||||||
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, token);
|
||||||
|
if (!bundle) {
|
||||||
|
throw new Error(`Recipient (user ID: ${recipientId}) has not set up encryption. They need to log in to initialize their encryption keys.`);
|
||||||
|
}
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate master key for file encryption
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
// Encrypt the master key using Signal Protocol
|
||||||
|
const mkBase64 = b64(mk);
|
||||||
|
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
||||||
|
|
||||||
|
// Add padding to obfuscate master key size
|
||||||
|
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
@@ -114,30 +229,48 @@ export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64
|
|||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: b64(encMsg.iv),
|
iv: b64(encMsg.iv),
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: b64(encMsg.ciphertext),
|
||||||
salt: b64(wkSalt),
|
salt: "", // Not used for Signal Protocol
|
||||||
iv2: b64(wrap.iv),
|
iv2: "", // Not used for Signal Protocol
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
wrappedMk: paddedMk // Padded Signal Protocol encrypted master key
|
||||||
} satisfies BaseDmEnvelope));
|
} satisfies BaseDmEnvelope));
|
||||||
|
|
||||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: getAuthHeaders(token, false),
|
headers: api.user.auth.getAuthHeaders(token, false),
|
||||||
body: form
|
body: form
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
export async function editDmEnvelope(id: number, recipientId: number, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
|
|
||||||
|
// Check if we have a session, if not, fetch prekey bundle and establish one
|
||||||
|
const hasSession = await signalService.hasSession(recipientId);
|
||||||
|
if (!hasSession) {
|
||||||
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(recipientId, authToken);
|
||||||
|
if (!bundle) {
|
||||||
|
throw new Error("No Signal Protocol prekey bundle available for recipient");
|
||||||
|
}
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate fresh master key for the edited message
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
// Encrypt the master key using Signal Protocol
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
const mkBase64 = b64(mk);
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
const encryptedMk = await signalService.encryptMessage(recipientId, mkBase64);
|
||||||
|
|
||||||
|
// Add padding to obfuscate master key size
|
||||||
|
const paddedMk = addPadding(JSON.stringify(encryptedMk));
|
||||||
|
|
||||||
|
// Encrypt the message content with the master key
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
await request({
|
await request({
|
||||||
type: "dmEdit",
|
type: "dmEdit",
|
||||||
@@ -146,9 +279,9 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
|
|||||||
id,
|
id,
|
||||||
iv: b64(encMsg.iv),
|
iv: b64(encMsg.iv),
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
ciphertext: b64(encMsg.ciphertext),
|
||||||
iv2: b64(wrap.iv),
|
iv2: "", // Not used for Signal Protocol
|
||||||
wrappedMk: b64(wrap.ciphertext),
|
wrappedMk: paddedMk, // Padded Signal Protocol encrypted master key
|
||||||
salt: b64(wkSalt)
|
salt: "" // Not used for Signal Protocol
|
||||||
}
|
}
|
||||||
} as DMEditRequest);
|
} as DMEditRequest);
|
||||||
}
|
}
|
||||||
@@ -167,9 +300,13 @@ export interface DMConversationResponse {
|
|||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export { fetchUsers, searchUsers } from "./users";
|
||||||
|
export { fetchUserPublicKey } from "./crypto/identity";
|
||||||
|
|
||||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||||
headers: getAuthHeaders(token, true)
|
headers: api.user.auth.getAuthHeaders(token, true)
|
||||||
});
|
});
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import * as userSearch from "./user/search";
|
|||||||
import * as cryptoPrekeys from "./crypto/prekeys";
|
import * as cryptoPrekeys from "./crypto/prekeys";
|
||||||
import * as cryptoIdentity from "./crypto/identity";
|
import * as cryptoIdentity from "./crypto/identity";
|
||||||
import * as cryptoBackup from "./crypto/backup";
|
import * as cryptoBackup from "./crypto/backup";
|
||||||
|
import * as cryptoSessions from "./crypto/sessions";
|
||||||
|
import * as cryptoMessagePlaintexts from "./crypto/messagePlaintexts";
|
||||||
import * as moderationBlocklist from "./moderation/blocklist";
|
import * as moderationBlocklist from "./moderation/blocklist";
|
||||||
import * as moderationUsers from "./moderation/users";
|
import * as moderationUsers from "./moderation/users";
|
||||||
import * as callsModule from "./calls";
|
import * as callsModule from "./calls";
|
||||||
@@ -27,7 +29,9 @@ const api = {
|
|||||||
crypto: {
|
crypto: {
|
||||||
prekeys: cryptoPrekeys,
|
prekeys: cryptoPrekeys,
|
||||||
identity: cryptoIdentity,
|
identity: cryptoIdentity,
|
||||||
backup: cryptoBackup
|
backup: cryptoBackup,
|
||||||
|
sessions: cryptoSessions,
|
||||||
|
messagePlaintexts: cryptoMessagePlaintexts
|
||||||
},
|
},
|
||||||
moderation: {
|
moderation: {
|
||||||
blocklist: moderationBlocklist,
|
blocklist: moderationBlocklist,
|
||||||
|
|||||||
@@ -1,29 +1,15 @@
|
|||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
import { randomBytes } from "@/utils/crypto/kdf";
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||||
import api from "@/core/api";
|
import { useUserStore } from "@/state/user";
|
||||||
import type { WrappedSessionKeyPayload } from "@/core/types";
|
import { fetchPreKeyBundle } from "@/core/api/crypto";
|
||||||
|
import { getAuthToken } from "@/core/api/account";
|
||||||
|
|
||||||
export interface CallSessionKey {
|
export interface CallSessionKey {
|
||||||
key: Uint8Array;
|
key: Uint8Array;
|
||||||
hash: string; // For emoji display
|
hash: string; // For emoji display
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallKeyExchange {
|
|
||||||
type: "call_key_exchange";
|
|
||||||
sessionKeyHash: string;
|
|
||||||
encryptedSessionKey: EncryptedCallMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EncryptedCallMessage {
|
|
||||||
iv: string;
|
|
||||||
ciphertext: string;
|
|
||||||
salt: string;
|
|
||||||
iv2: string;
|
|
||||||
wrappedSessionKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a new call session key for end-to-end encryption
|
* Generates a new call session key for end-to-end encryption
|
||||||
* @returns Promise that resolves to a session key with its hash for display
|
* @returns Promise that resolves to a session key with its hash for display
|
||||||
@@ -60,97 +46,6 @@ export async function rotateCallSessionKey(): Promise<CallSessionKey> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create session key from hash (for backward compatibility)
|
|
||||||
* @deprecated Use deriveCallSessionKeyFromSharedSecret instead
|
|
||||||
*/
|
|
||||||
export async function createCallSessionKeyFromHash(hash: string): Promise<CallSessionKey> {
|
|
||||||
// For backward compatibility, generate a deterministic key from the hash
|
|
||||||
const hashBytes = ub64(hash);
|
|
||||||
const sessionKey = new Uint8Array(32);
|
|
||||||
|
|
||||||
// Repeat the hash bytes to fill 32 bytes
|
|
||||||
for (let i = 0; i < 32; i++) {
|
|
||||||
sessionKey[i] = hashBytes[i % hashBytes.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: sessionKey,
|
|
||||||
hash
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive session key from ECDH shared secret and session key hash
|
|
||||||
* This creates a deterministic but cryptographically secure key
|
|
||||||
*/
|
|
||||||
export async function deriveCallSessionKeyFromSharedSecret(
|
|
||||||
sharedSecret: Uint8Array,
|
|
||||||
sessionKeyHash: string,
|
|
||||||
isInitiator: boolean
|
|
||||||
): Promise<CallSessionKey> {
|
|
||||||
// Use HKDF to derive the session key from the shared secret
|
|
||||||
// Include the session key hash and role to ensure uniqueness
|
|
||||||
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
|
|
||||||
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
|
|
||||||
|
|
||||||
// Import the shared secret as a raw key for HKDF
|
|
||||||
const sharedKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
sharedSecret.buffer as ArrayBuffer,
|
|
||||||
{ name: 'HKDF' },
|
|
||||||
false,
|
|
||||||
['deriveKey']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Derive the session key using HKDF
|
|
||||||
const sessionKey = await crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'HKDF',
|
|
||||||
hash: 'SHA-256',
|
|
||||||
salt: salt,
|
|
||||||
info: info
|
|
||||||
},
|
|
||||||
sharedKey,
|
|
||||||
{ name: 'AES-GCM', length: 256 },
|
|
||||||
true, // Make the key extractable so we can export it
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Export the raw key material
|
|
||||||
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: new Uint8Array(sessionKeyMaterial),
|
|
||||||
hash: sessionKeyHash
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a call signaling message with the session key
|
|
||||||
*/
|
|
||||||
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
|
|
||||||
const messageKey = await importAesGcmKey(sessionKey);
|
|
||||||
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
iv: b64(encrypted.iv),
|
|
||||||
ciphertext: b64(encrypted.ciphertext),
|
|
||||||
salt: "", // Not used for message encryption, only for key wrapping
|
|
||||||
iv2: "",
|
|
||||||
wrappedSessionKey: ""
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt a call signaling message
|
|
||||||
*/
|
|
||||||
export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise<Record<string, unknown>> {
|
|
||||||
const messageKey = await importAesGcmKey(sessionKey);
|
|
||||||
const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext));
|
|
||||||
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate 4 emojis representing the call session key
|
* Generate 4 emojis representing the call session key
|
||||||
*/
|
*/
|
||||||
@@ -176,63 +71,65 @@ export function generateCallEmojis(sessionKeyHash: string): string[] {
|
|||||||
return emojis;
|
return emojis;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HKDF info for CALL key wrapping (distinct from DM's info)
|
|
||||||
const CALL_INFO = new Uint8Array([2]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a call session key for a specific recipient using ECDH key exchange
|
* Encrypts a call session key using Signal Protocol
|
||||||
* @param recipientPublicKeyB64 - The recipient's public key in base64 format
|
* @param recipientId - The recipient's user ID
|
||||||
* @param sessionKey - The session key to wrap
|
* @param sessionKey - The session key to encrypt
|
||||||
* @returns Promise that resolves to the wrapped session key payload
|
* @returns Promise that resolves to encrypted session key data
|
||||||
*/
|
*/
|
||||||
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
|
export async function encryptCallSessionKey(recipientId: number, sessionKey: Uint8Array): Promise<{ type: number; body: string }> {
|
||||||
const keys = api.user.auth.getCurrentKeys();
|
const user = useUserStore.getState().user.currentUser;
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!user?.id) {
|
||||||
|
throw new Error("User not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
const salt = randomBytes(16);
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
// Ensure we have a session with the recipient
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
const hasSession = await signalService.hasSession(recipientId);
|
||||||
const wrap = await aesGcmEncrypt(wk, sessionKey);
|
if (!hasSession) {
|
||||||
return {
|
// Fetch prekey bundle and establish session
|
||||||
salt: b64(salt),
|
const token = getAuthToken();
|
||||||
iv2: b64(wrap.iv),
|
if (!token) {
|
||||||
wrapped: b64(wrap.ciphertext)
|
throw new Error("No auth token");
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const bundle = await fetchPreKeyBundle(recipientId, token);
|
||||||
|
if (!bundle) {
|
||||||
|
throw new Error("No prekey bundle available for recipient");
|
||||||
|
}
|
||||||
|
|
||||||
|
await signalService.processPreKeyBundle(recipientId, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the session key using Signal Protocol
|
||||||
|
const sessionKeyString = b64(sessionKey);
|
||||||
|
const encrypted = await signalService.encryptMessage(recipientId, sessionKeyString);
|
||||||
|
|
||||||
|
return encrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a shared secret and derive session key for the receiver
|
* Decrypts a call session key using Signal Protocol
|
||||||
|
* @param senderId - The sender's user ID
|
||||||
|
* @param encryptedKey - The encrypted session key data
|
||||||
|
* @returns Promise that resolves to the decrypted session key
|
||||||
*/
|
*/
|
||||||
export async function createSharedSecretAndDeriveSessionKey(
|
export async function decryptCallSessionKey(senderId: number, encryptedKey: { type: number; body: string }): Promise<Uint8Array> {
|
||||||
senderPublicKeyB64: string,
|
const user = useUserStore.getState().user.currentUser;
|
||||||
sessionKeyHash: string,
|
if (!user?.id) {
|
||||||
isInitiator: boolean
|
throw new Error("User not authenticated");
|
||||||
): Promise<CallSessionKey> {
|
|
||||||
const keys = api.user.auth.getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
// Create shared secret using ECDH
|
|
||||||
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
|
||||||
|
|
||||||
// Derive the session key from the shared secret
|
|
||||||
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
* Unwraps a call session key received from a sender using ECDH key exchange
|
|
||||||
* @param senderPublicKeyB64 - The sender's public key in base64 format
|
|
||||||
* @param payload - The wrapped session key payload
|
|
||||||
* @returns Promise that resolves to the unwrapped session key
|
|
||||||
*/
|
|
||||||
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
|
|
||||||
const keys = api.user.auth.getCurrentKeys();
|
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
|
||||||
|
|
||||||
const salt = ub64(payload.salt);
|
// Decrypt using Signal Protocol
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
const decryptedString = await signalService.decryptMessage(senderId, encryptedKey);
|
||||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
// Convert back to Uint8Array
|
||||||
const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped));
|
const sessionKey = new Uint8Array(
|
||||||
return new Uint8Array(sessionKey);
|
atob(decryptedString).split("").map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
return sessionKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData } from "@/core/types";
|
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData, CallSessionKeyData } from "@/core/types";
|
||||||
import * as WebRTC from "./webrtc";
|
import * as WebRTC from "./webrtc";
|
||||||
|
|
||||||
export interface CallState {
|
export interface CallState {
|
||||||
@@ -133,12 +133,16 @@ export class CallSignalingHandler {
|
|||||||
|
|
||||||
private handleCallSessionKey(message: CallSignalingMessage) {
|
private handleCallSessionKey(message: CallSignalingMessage) {
|
||||||
const state = this.getState();
|
const state = this.getState();
|
||||||
const { sessionKeyHash, data } = message;
|
const { sessionKeyHash } = message;
|
||||||
|
const data = message.data as CallSessionKeyData;
|
||||||
|
|
||||||
if (sessionKeyHash) {
|
if (sessionKeyHash) {
|
||||||
state.setCallSessionKeyHash(sessionKeyHash);
|
state.setCallSessionKeyHash(sessionKeyHash);
|
||||||
}
|
}
|
||||||
if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) {
|
|
||||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash);
|
// Check if data is CallSessionKeyData and has encryptedSessionKey
|
||||||
|
if (data && data.encryptedSessionKey && message.fromUserId) {
|
||||||
|
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.encryptedSessionKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
|
import type { CallSignalingMessage } from "@/core/types";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
|
import { encryptCallSessionKey, decryptCallSessionKey, rotateCallSessionKey } from "./encryption";
|
||||||
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
||||||
import E2EEWorker from "./e2eeWorker?worker";
|
import E2EEWorker from "./e2eeWorker?worker";
|
||||||
import { delay } from "@/utils/utils";
|
import { delay } from "@/utils/utils";
|
||||||
@@ -855,21 +855,18 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string)
|
|||||||
|
|
||||||
export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
|
export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!);
|
// Encrypt session key using Signal Protocol
|
||||||
if (!recipientPublicKey) {
|
const encrypted = await encryptCallSessionKey(userId, sessionKey);
|
||||||
console.warn("No recipient public key for", userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const wrapped = await wrapCallSessionKeyForRecipient(recipientPublicKey, sessionKey);
|
|
||||||
await sendSignalingMessage({
|
await sendSignalingMessage({
|
||||||
type: "call_session_key",
|
type: "call_session_key",
|
||||||
fromUserId: 0,
|
fromUserId: 0,
|
||||||
toUserId: userId,
|
toUserId: userId,
|
||||||
sessionKeyHash,
|
sessionKeyHash,
|
||||||
data: { wrappedSessionKey: wrapped }
|
data: { encryptedSessionKey: encrypted }
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to send wrapped session key:", e);
|
console.error("Failed to send encrypted session key:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,31 +883,21 @@ export async function setSessionKey(userId: number, keyBytes: Uint8Array): Promi
|
|||||||
|
|
||||||
export async function receiveWrappedSessionKey(
|
export async function receiveWrappedSessionKey(
|
||||||
fromUserId: number,
|
fromUserId: number,
|
||||||
wrappedPayload: WrappedSessionKeyPayload,
|
encryptedKey: { type: number; body: string }
|
||||||
sessionKeyHash?: string
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!);
|
if (!encryptedKey) {
|
||||||
if (!senderPublicKey) {
|
console.error("Missing encrypted session key");
|
||||||
console.error("Failed to get sender public key");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!wrappedPayload || !sessionKeyHash) {
|
|
||||||
console.error("Missing wrapped payload or session key hash");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap the session key from the encrypted payload
|
// Decrypt the session key using Signal Protocol
|
||||||
const unwrappedSessionKey = await unwrapCallSessionKeyFromSender(senderPublicKey, {
|
const sessionKey = await decryptCallSessionKey(fromUserId, encryptedKey);
|
||||||
salt: wrappedPayload.salt,
|
|
||||||
iv2: wrappedPayload.iv2,
|
|
||||||
wrapped: wrappedPayload.wrapped
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use the unwrapped session key directly (both sides should have the same key)
|
// Use the decrypted session key for media encryption
|
||||||
await setSessionKey(fromUserId, unwrappedSessionKey);
|
await setSessionKey(fromUserId, sessionKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to unwrap session key:", e);
|
console.error("Failed to decrypt session key:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-7
@@ -505,7 +505,7 @@ export interface CallEndData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CallSessionKeyData {
|
export interface CallSessionKeyData {
|
||||||
wrappedSessionKey?: WrappedSessionKeyPayload;
|
encryptedSessionKey: { type: number; body: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallVideoToggleData {
|
export interface CallVideoToggleData {
|
||||||
@@ -526,12 +526,6 @@ export interface CallScreenShareToggleMessageData {
|
|||||||
data: CallScreenShareToggleData;
|
data: CallScreenShareToggleData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WrappedSessionKeyPayload {
|
|
||||||
salt: string;
|
|
||||||
iv2: string;
|
|
||||||
wrapped: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
||||||
type: "call_video_toggle";
|
type: "call_video_toggle";
|
||||||
data: CallVideoToggleData;
|
data: CallVideoToggleData;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { request } from "./websocket";
|
import { send } from "./websocket";
|
||||||
import type {
|
import type {
|
||||||
TypingWebSocketMessage,
|
TypingWebSocketMessage,
|
||||||
StopTypingWebSocketMessage,
|
StopTypingWebSocketMessage,
|
||||||
@@ -39,7 +39,6 @@ export class TypingManager {
|
|||||||
async sendTyping(): Promise<void> {
|
async sendTyping(): Promise<void> {
|
||||||
if (!this.authToken) return;
|
if (!this.authToken) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const message: TypingRequest = {
|
const message: TypingRequest = {
|
||||||
type: "typing",
|
type: "typing",
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -49,11 +48,9 @@ export class TypingManager {
|
|||||||
data: {}
|
data: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
await request(message);
|
// Fire-and-forget - don't wait for response
|
||||||
|
send(message);
|
||||||
this.scheduleStopTyping("public");
|
this.scheduleStopTyping("public");
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send typing indicator:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,7 +59,6 @@ export class TypingManager {
|
|||||||
async sendStopTyping(): Promise<void> {
|
async sendStopTyping(): Promise<void> {
|
||||||
if (!this.authToken) return;
|
if (!this.authToken) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const message: StopTypingRequest = {
|
const message: StopTypingRequest = {
|
||||||
type: "stopTyping",
|
type: "stopTyping",
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -72,11 +68,9 @@ export class TypingManager {
|
|||||||
data: {}
|
data: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
await request(message);
|
// Fire-and-forget - don't wait for response
|
||||||
|
send(message);
|
||||||
this.clearStopTypingTimeout("public");
|
this.clearStopTypingTimeout("public");
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send stop typing indicator:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +79,6 @@ export class TypingManager {
|
|||||||
async sendDmTyping(recipientId: number): Promise<void> {
|
async sendDmTyping(recipientId: number): Promise<void> {
|
||||||
if (!this.authToken) return;
|
if (!this.authToken) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const message: DmTypingRequest = {
|
const message: DmTypingRequest = {
|
||||||
type: "dmTyping",
|
type: "dmTyping",
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -97,11 +90,9 @@ export class TypingManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await request(message);
|
// Fire-and-forget - don't wait for response
|
||||||
|
send(message);
|
||||||
this.scheduleStopDmTyping(recipientId);
|
this.scheduleStopDmTyping(recipientId);
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send DM typing indicator:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,7 +101,6 @@ export class TypingManager {
|
|||||||
async sendStopDmTyping(recipientId: number): Promise<void> {
|
async sendStopDmTyping(recipientId: number): Promise<void> {
|
||||||
if (!this.authToken) return;
|
if (!this.authToken) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const message: StopDmTypingRequest = {
|
const message: StopDmTypingRequest = {
|
||||||
type: "stopDmTyping",
|
type: "stopDmTyping",
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -122,11 +112,9 @@ export class TypingManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await request(message);
|
// Fire-and-forget - don't wait for response
|
||||||
|
send(message);
|
||||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send stop DM typing indicator:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { openDB, type IDBPDatabase } from "idb";
|
import { openDB, type IDBPDatabase } from "idb";
|
||||||
import type { WebSocketCredentials, WebSocketMessage } from "./types";
|
|
||||||
|
|
||||||
interface UpdateMessage<T = any> {
|
interface UpdateMessage<T = any> {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -72,28 +71,18 @@ export async function setLastSequence(seq: number): Promise<void> {
|
|||||||
* Process a batched updates message
|
* Process a batched updates message
|
||||||
* @param message - The batched updates message from the server
|
* @param message - The batched updates message from the server
|
||||||
* @param handler - Function to handle individual updates
|
* @param handler - Function to handle individual updates
|
||||||
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
|
|
||||||
*/
|
*/
|
||||||
export async function processBatchedUpdates(
|
export async function processBatchedUpdates(
|
||||||
message: BatchedUpdatesMessage,
|
message: BatchedUpdatesMessage,
|
||||||
handler: (update: UpdateMessage) => void,
|
handler: (update: UpdateMessage) => void
|
||||||
requestMissedFn?: (lastSeq: number) => Promise<void>
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { seq, updates } = message;
|
const { seq, updates } = message;
|
||||||
const lastSeq = await getLastSequence();
|
const lastSeq = await getLastSequence();
|
||||||
|
|
||||||
// Check for gap
|
// Log gap for debugging, but don't try to recover (getUpdates doesn't work properly)
|
||||||
if (seq !== lastSeq + 1 && lastSeq > 0) {
|
if (seq !== lastSeq + 1 && lastSeq > 0) {
|
||||||
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`);
|
const gapSize = seq - (lastSeq + 1);
|
||||||
|
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq} (gap size: ${gapSize}). Skipping ${gapSize} updates.`);
|
||||||
// Request missing updates if function provided
|
|
||||||
if (requestMissedFn) {
|
|
||||||
try {
|
|
||||||
await requestMissedFn(lastSeq);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to request missed updates for gap:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process all updates in the batch
|
// Process all updates in the batch
|
||||||
@@ -104,23 +93,3 @@ export async function processBatchedUpdates(
|
|||||||
// Update last sequence number
|
// Update last sequence number
|
||||||
await setLastSequence(seq);
|
await setLastSequence(seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Request missed updates from the server
|
|
||||||
* @param lastSeq - The last sequence number we received
|
|
||||||
* @param requestFn - Function to send the request to the server
|
|
||||||
* @param credentials - Optional WebSocket credentials for authentication
|
|
||||||
*/
|
|
||||||
export async function requestMissedUpdates(
|
|
||||||
lastSeq: number,
|
|
||||||
requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise<void>,
|
|
||||||
credentials?: WebSocketCredentials
|
|
||||||
): Promise<void> {
|
|
||||||
if (lastSeq > 0) {
|
|
||||||
await requestFn({
|
|
||||||
type: "getUpdates",
|
|
||||||
data: { lastSeq },
|
|
||||||
credentials
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import { CallSignalingHandler } from "./calls/signaling";
|
|||||||
import { onlineStatusManager } from "./onlineStatusManager";
|
import { onlineStatusManager } from "./onlineStatusManager";
|
||||||
import { typingManager } from "./typingManager";
|
import { typingManager } from "./typingManager";
|
||||||
import { useUserStore } from "@/state/user";
|
import { useUserStore } from "@/state/user";
|
||||||
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
|
import { processBatchedUpdates } from "./updateManager";
|
||||||
import { getAuthToken } from "@/core/api/user/auth";
|
import { getAuthToken } from "@/core/api/user/auth";
|
||||||
|
|
||||||
interface HttpError extends Error {
|
interface HttpError extends Error {
|
||||||
@@ -161,21 +161,10 @@ function setupEventHandlers(): void {
|
|||||||
|
|
||||||
// Handle batched updates
|
// Handle batched updates
|
||||||
if (response.type === "updates" && "seq" in response && "updates" in response) {
|
if (response.type === "updates" && "seq" in response && "updates" in response) {
|
||||||
// Create function to request missed updates with credentials
|
|
||||||
const token = getAuthToken();
|
|
||||||
const requestMissedFn = token ? async (lastSeq: number) => {
|
|
||||||
await requestMissedUpdates(lastSeq, async (req) => {
|
|
||||||
await request(req);
|
|
||||||
}, {
|
|
||||||
scheme: "Bearer",
|
|
||||||
credentials: token
|
|
||||||
});
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
await processBatchedUpdates(response as any, (update) => {
|
await processBatchedUpdates(response as any, (update) => {
|
||||||
// Route individual updates to appropriate handlers
|
// Route individual updates to appropriate handlers
|
||||||
handleUpdate(update);
|
handleUpdate(update);
|
||||||
}, requestMissedFn);
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,20 +239,9 @@ function setupEventHandlers(): void {
|
|||||||
console.error("Failed to send ping on reconnect:", error);
|
console.error("Failed to send ping on reconnect:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send last sequence number and request missed updates on reconnect
|
// Note: We don't request missed updates on reconnect because getUpdates
|
||||||
// Wait a bit for ping to complete authentication
|
// doesn't properly return updates (they're sent directly via WebSocket
|
||||||
await delay(100);
|
// but the client can't handle them). Gaps will be logged but not recovered.
|
||||||
|
|
||||||
try {
|
|
||||||
const lastSeq = await getLastSequence();
|
|
||||||
if (lastSeq > 0) {
|
|
||||||
await requestMissedUpdates(lastSeq, async (req) => {
|
|
||||||
await request(req);
|
|
||||||
}, credentials);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to request missed updates:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to authenticate on reconnect:", error);
|
console.error("Failed to authenticate on reconnect:", error);
|
||||||
@@ -359,6 +337,22 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a WebSocket message without waiting for a response (fire-and-forget)
|
||||||
|
* Useful for typing indicators and other non-critical messages
|
||||||
|
*/
|
||||||
|
export function send<T = unknown>(payload: WebSocketMessage<T>): void {
|
||||||
|
if (websocket.readyState !== WebSocket.OPEN) {
|
||||||
|
console.warn("WebSocket is not open, cannot send message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
websocket.send(JSON.stringify(payload));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send WebSocket message:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --------------
|
// --------------
|
||||||
// Initialization
|
// Initialization
|
||||||
// --------------
|
// --------------
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { AuthContainer } from "./Auth";
|
import { AuthContainer } from "./Auth";
|
||||||
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
|
import { useState, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
import { LoginForm } from "./LoginForm";
|
import { LoginForm } from "./LoginForm";
|
||||||
import { RegisterForm } from "./RegisterForm";
|
import { RegisterForm } from "./RegisterForm";
|
||||||
import type { Variants, Transition } from "motion/react";
|
import type { Variants, Transition } from "motion/react";
|
||||||
import styles from "./auth.module.scss";
|
import styles from "./auth.module.scss";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
|
||||||
const slideVariants: Variants = {
|
const slideVariants: Variants = {
|
||||||
enter: (direction: number) => ({
|
enter: (direction: number) => ({
|
||||||
@@ -37,8 +38,8 @@ const slideTransition: Transition = {
|
|||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||||
if (navigateDownloadApp) return navigateDownloadApp;
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
const [direction, setDirection] = useState(0);
|
const [direction, setDirection] = useState(0);
|
||||||
const prevMode = useRef(searchParams.get("mode") || "login");
|
const prevMode = useRef(searchParams.get("mode") || "login");
|
||||||
@@ -49,12 +50,33 @@ export default function AuthPage() {
|
|||||||
const currentMode = searchParams.get("mode") || "login";
|
const currentMode = searchParams.get("mode") || "login";
|
||||||
const enteringElementRef = useRef<"login" | "register" | null>(null);
|
const enteringElementRef = useRef<"login" | "register" | null>(null);
|
||||||
const [effectActivated, setEffectActivated] = useState(false);
|
const [effectActivated, setEffectActivated] = useState(false);
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (prevMode.current !== currentMode) {
|
if (prevMode.current !== currentMode) {
|
||||||
setDirection(currentMode === "register" ? 1 : -1);
|
const previousMode = prevMode.current;
|
||||||
|
|
||||||
|
// Measure the exiting form's height BEFORE changing anything
|
||||||
|
// This works whether it's relative or absolute
|
||||||
|
const exitingComponent = previousMode === "login" ? loginFormRef.current : registerFormRef.current;
|
||||||
|
let measuredHeight: number | null = null;
|
||||||
|
if (exitingComponent) {
|
||||||
|
const height = exitingComponent.scrollHeight;
|
||||||
|
if (height > 0) {
|
||||||
|
measuredHeight = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mode and direction first
|
||||||
prevMode.current = currentMode;
|
prevMode.current = currentMode;
|
||||||
|
setDirection(currentMode === "register" ? 1 : -1);
|
||||||
enteringElementRef.current = currentMode as "login" | "register";
|
enteringElementRef.current = currentMode as "login" | "register";
|
||||||
|
|
||||||
|
// Set height and transition state together
|
||||||
|
if (measuredHeight !== null) {
|
||||||
|
setContainerHeight(measuredHeight);
|
||||||
|
}
|
||||||
|
setIsTransitioning(true);
|
||||||
}
|
}
|
||||||
}, [currentMode]);
|
}, [currentMode]);
|
||||||
|
|
||||||
@@ -91,6 +113,12 @@ export default function AuthPage() {
|
|||||||
};
|
};
|
||||||
}, [currentMode]);
|
}, [currentMode]);
|
||||||
|
|
||||||
|
// Now we can do conditional returns after all hooks are called
|
||||||
|
if (navigateDownloadApp) return navigateDownloadApp;
|
||||||
|
if (user.authToken && user.currentUser) {
|
||||||
|
return <Navigate to="/chat" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function switchMode(newMode: "login" | "register") {
|
function switchMode(newMode: "login" | "register") {
|
||||||
navigate(`/auth?mode=${newMode}`, { replace: true });
|
navigate(`/auth?mode=${newMode}`, { replace: true });
|
||||||
}
|
}
|
||||||
@@ -105,6 +133,7 @@ export default function AuthPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (currentMode === mode && enteringElementRef.current === mode) {
|
if (currentMode === mode && enteringElementRef.current === mode) {
|
||||||
enteringElementRef.current = null;
|
enteringElementRef.current = null;
|
||||||
|
setIsTransitioning(false);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -129,9 +158,6 @@ export default function AuthPage() {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
||||||
transition: "height 0.3s ease"
|
transition: "height 0.3s ease"
|
||||||
}}
|
|
||||||
onAnimationStart={() => {
|
|
||||||
|
|
||||||
}}
|
}}
|
||||||
onAnimationEnd={() => {
|
onAnimationEnd={() => {
|
||||||
setContainerHeight("auto");
|
setContainerHeight("auto");
|
||||||
@@ -151,7 +177,7 @@ export default function AuthPage() {
|
|||||||
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
||||||
className={styles.formWrapper}
|
className={styles.formWrapper}
|
||||||
style={{
|
style={{
|
||||||
position: containerHeight === "auto" ? "relative" : "absolute"
|
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LoginForm onSwitchMode={() => switchMode("register")} />
|
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||||
@@ -168,6 +194,9 @@ export default function AuthPage() {
|
|||||||
transition={slideTransition}
|
transition={slideTransition}
|
||||||
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
||||||
className={styles.formWrapper}
|
className={styles.formWrapper}
|
||||||
|
style={{
|
||||||
|
position: (containerHeight === "auto" && !isTransitioning) ? "relative" : "absolute"
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -79,7 +79,14 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
console.log("========================================");
|
||||||
|
console.log("[LoginForm] 🚀 LOGIN FORM SUBMITTED");
|
||||||
|
console.log("[LoginForm] Username:", username);
|
||||||
|
console.log("[LoginForm] Has password:", !!password);
|
||||||
|
console.log("========================================");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("[LoginForm] Deriving auth secret...");
|
||||||
const derived = await api.user.auth.deriveAuthSecret(username, password);
|
const derived = await api.user.auth.deriveAuthSecret(username, password);
|
||||||
const request: LoginRequest = {
|
const request: LoginRequest = {
|
||||||
username: username,
|
username: username,
|
||||||
@@ -87,13 +94,52 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("[LoginForm] Calling login API...");
|
||||||
const data = await api.user.auth.login(request);
|
const data = await api.user.auth.login(request);
|
||||||
|
console.log("[LoginForm] Login successful, user ID:", data.user?.id);
|
||||||
|
|
||||||
setUser(data.token, data.user);
|
setUser(data.token, data.user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("[LoginForm] Ensuring keys on login...");
|
||||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||||
|
console.log("[LoginForm] Keys ensured");
|
||||||
|
|
||||||
|
// Initialize Signal Protocol after keys are set up (non-blocking)
|
||||||
|
if (data.user?.id) {
|
||||||
|
console.log("[LoginForm] ✅ User ID exists, scheduling Signal Protocol initialization");
|
||||||
|
// Run Signal Protocol initialization in background to avoid blocking navigation
|
||||||
|
// Use setTimeout to ensure it runs even if navigation happens
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
console.log("[LoginForm] 🚀 Starting Signal Protocol initialization...");
|
||||||
|
const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
|
||||||
|
await initializeSignalProtocol({
|
||||||
|
userId: data.user!.id.toString(),
|
||||||
|
password,
|
||||||
|
token: data.token,
|
||||||
|
restoreSessions: true,
|
||||||
|
uploadSessions: true
|
||||||
|
});
|
||||||
|
console.log("[LoginForm] ✅ Signal Protocol initialization completed");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Key setup failed:", e);
|
console.error("[LoginForm] ❌ Signal Protocol initialization failed:", e);
|
||||||
|
console.error("[LoginForm] Error details:", {
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
stack: e instanceof Error ? e.stack : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
console.log("[LoginForm] ✅ Signal Protocol initialization scheduled");
|
||||||
|
} else {
|
||||||
|
console.warn("[LoginForm] ⚠️ No user ID, skipping Signal Protocol initialization");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[LoginForm] ❌ Key setup failed:", e);
|
||||||
|
console.error("[LoginForm] Error details:", {
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
stack: e instanceof Error ? e.stack : undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure WebSocket is connected and authenticated
|
// Ensure WebSocket is connected and authenticated
|
||||||
|
|||||||
@@ -116,10 +116,29 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.user.auth.register(request);
|
const data = await api.user.auth.register(request);
|
||||||
|
|
||||||
setUser(data.token, data.user);
|
setUser(data.token, data.user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||||
|
|
||||||
|
// Initialize Signal Protocol after keys are set up (non-blocking)
|
||||||
|
if (data.user?.id) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { initializeSignalProtocol } = await import("@/utils/crypto/signalProtocolInit");
|
||||||
|
await initializeSignalProtocol({
|
||||||
|
userId: data.user!.id.toString(),
|
||||||
|
password,
|
||||||
|
token: data.token,
|
||||||
|
restoreSessions: false,
|
||||||
|
uploadSessions: true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[RegisterForm] Signal Protocol initialization failed:", e);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Key setup failed:", e);
|
console.error("Key setup failed:", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|||||||
import { useUserStore } from "@/state/user";
|
import { useUserStore } from "@/state/user";
|
||||||
import { useChatStore } from "@/state/chat";
|
import { useChatStore } from "@/state/chat";
|
||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
|
import { decryptDm, sendDMViaWebSocket } from "@/core/api/dm";
|
||||||
import type { ConversationResponse } from "@/core/api/chats/dm";
|
import type { ConversationResponse } from "@/core/api/chats/dm";
|
||||||
import type { User, Message, DmEncryptedJSON } from "@/core/types";
|
import type { User, Message, DmEncryptedJSON } from "@/core/types";
|
||||||
import { websocket } from "@/core/websocket";
|
import { websocket } from "@/core/websocket";
|
||||||
@@ -51,6 +52,10 @@ export function useDM() {
|
|||||||
if (!user.authToken) return;
|
if (!user.authToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Wait for session restoration to complete (if in progress)
|
||||||
|
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
|
||||||
|
await waitForSessionRestore();
|
||||||
|
|
||||||
// Get public key
|
// Get public key
|
||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken);
|
||||||
if (!publicKey) return;
|
if (!publicKey) return;
|
||||||
@@ -62,11 +67,22 @@ export function useDM() {
|
|||||||
// Find last message
|
// Find last message
|
||||||
const lastMessage = messages[messages.length - 1];
|
const lastMessage = messages[messages.length - 1];
|
||||||
let lastPlaintext: string | null = null;
|
let lastPlaintext: string | null = null;
|
||||||
|
const isAuthor = lastMessage.senderId === user.currentUser?.id;
|
||||||
try {
|
try {
|
||||||
lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
|
if (isAuthor) {
|
||||||
|
// For our own messages, fetch plaintexts from server (encrypted at rest)
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(dmUser.id);
|
||||||
|
const cached = plaintexts.get(lastMessage.id);
|
||||||
|
if (cached) {
|
||||||
|
lastPlaintext = (JSON.parse(cached) as DmEncryptedJSON).data.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Incoming message - decrypt via Signal
|
||||||
|
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, lastMessage.senderId)) as DmEncryptedJSON).data.content;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to decrypt last message:", error);
|
console.error("Failed to get last message preview:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate unread count
|
// Calculate unread count
|
||||||
@@ -101,6 +117,10 @@ export function useDM() {
|
|||||||
usersLoadedRef.current = true;
|
usersLoadedRef.current = true;
|
||||||
setIsLoadingUsers(true);
|
setIsLoadingUsers(true);
|
||||||
try {
|
try {
|
||||||
|
// Wait for session restoration to complete (if in progress)
|
||||||
|
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
|
||||||
|
await waitForSessionRestore();
|
||||||
|
|
||||||
const conversations = await api.chats.dm.conversations(user.authToken);
|
const conversations = await api.chats.dm.conversations(user.authToken);
|
||||||
|
|
||||||
// Process conversations and decrypt last messages
|
// Process conversations and decrypt last messages
|
||||||
@@ -110,20 +130,30 @@ export function useDM() {
|
|||||||
|
|
||||||
if (conv.lastMessage) {
|
if (conv.lastMessage) {
|
||||||
try {
|
try {
|
||||||
// Get the public key for the other user
|
const isAuthor = conv.lastMessage.senderId === user.currentUser?.id;
|
||||||
const otherUserId = conv.lastMessage.senderId === user.currentUser?.id
|
const otherUserId = conv.user.id; // the other party in the conversation
|
||||||
? conv.lastMessage.recipientId
|
if (isAuthor) {
|
||||||
: conv.lastMessage.senderId;
|
// Fetch plaintext of our own last message from server
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
|
||||||
|
const cached = plaintexts.get(conv.lastMessage.id);
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached) as DmEncryptedJSON;
|
||||||
|
lastMessageContent = formatDMMessageContent(data.data.content, conv.lastMessage.senderId, user.currentUser!.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Incoming message - decrypt
|
||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||||
if (publicKey) {
|
if (publicKey) {
|
||||||
// Decrypt the last message
|
const decryptedJson = await decryptDm(conv.lastMessage, conv.lastMessage.senderId);
|
||||||
const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!);
|
|
||||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!);
|
lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser!.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to decrypt last message for user", conv.user.id, error);
|
// Silently fail for last message decryption - it's not critical
|
||||||
|
// The message will just show "No messages" instead
|
||||||
|
console.debug("Failed to decrypt last message for user", conv.user.id, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,19 +182,43 @@ export function useDM() {
|
|||||||
}, [user.authToken]);
|
}, [user.authToken]);
|
||||||
|
|
||||||
// Load DM history for active conversation
|
// Load DM history for active conversation
|
||||||
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
|
const loadDMHistory = useCallback(async (userId: number) => {
|
||||||
if (!user.authToken || isLoadingHistory) return;
|
if (!user.authToken || isLoadingHistory) return;
|
||||||
|
|
||||||
setIsLoadingHistory(true);
|
setIsLoadingHistory(true);
|
||||||
try {
|
try {
|
||||||
|
// Wait for session restoration to complete (if in progress)
|
||||||
|
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
|
||||||
|
await waitForSessionRestore();
|
||||||
|
console.log(`[useDM] Session restoration complete, proceeding with message load for user ${userId}`);
|
||||||
|
|
||||||
const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50);
|
const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50);
|
||||||
const decryptedMessages: Message[] = [];
|
const decryptedMessages: Message[] = [];
|
||||||
let maxIncomingId = 0;
|
let maxIncomingId = 0;
|
||||||
|
|
||||||
for (const env of messages) {
|
for (const env of messages) {
|
||||||
try {
|
try {
|
||||||
const text = await api.chats.dm.decrypt(env, publicKey);
|
// Check if this is a message sent by the current user
|
||||||
const isAuthor = env.senderId !== userId;
|
const isAuthor = env.senderId === user.currentUser?.id;
|
||||||
|
let text: string;
|
||||||
|
|
||||||
|
if (isAuthor) {
|
||||||
|
// For sent messages, we can't decrypt them in Signal Protocol
|
||||||
|
// Try to get the plaintext from the server (encrypted)
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(userId);
|
||||||
|
const cached = plaintexts.get(env.id);
|
||||||
|
if (cached) {
|
||||||
|
text = cached;
|
||||||
|
} else {
|
||||||
|
// Not on server - skip this message
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Decrypt incoming messages
|
||||||
|
text = await decryptDm(env, env.senderId);
|
||||||
|
}
|
||||||
|
|
||||||
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
|
||||||
|
|
||||||
decryptedMessages.push({
|
decryptedMessages.push({
|
||||||
@@ -204,11 +258,11 @@ export function useDM() {
|
|||||||
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
|
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
|
||||||
|
|
||||||
// Send DM message
|
// Send DM message
|
||||||
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
|
const sendDMMessage = useCallback(async (recipientId: number, content: string) => {
|
||||||
if (!user.authToken) return;
|
if (!user.authToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.chats.dm.send(recipientId, publicKey, content, user.authToken);
|
await sendDMViaWebSocket(recipientId, content, user.authToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send DM:", error);
|
console.error("Failed to send DM:", error);
|
||||||
}
|
}
|
||||||
@@ -234,7 +288,7 @@ export function useDM() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load conversation history
|
// Load conversation history
|
||||||
await loadDMHistory(dmUser.id, publicKey);
|
await loadDMHistory(dmUser.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start DM conversation:", error);
|
console.error("Failed to start DM conversation:", error);
|
||||||
}
|
}
|
||||||
@@ -259,17 +313,23 @@ export function useDM() {
|
|||||||
|
|
||||||
if (userConversation.lastMessage) {
|
if (userConversation.lastMessage) {
|
||||||
try {
|
try {
|
||||||
// Get the public key for the other user
|
const isAuthor = userConversation.lastMessage.senderId === user.currentUser?.id;
|
||||||
const otherUserId = userConversation.lastMessage.senderId === user.currentUser?.id
|
const otherUserId = userId;
|
||||||
? userConversation.lastMessage.recipientId
|
if (isAuthor) {
|
||||||
: userConversation.lastMessage.senderId;
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
|
||||||
|
const cached = plaintexts.get(userConversation.lastMessage.id);
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached) as DmEncryptedJSON;
|
||||||
|
lastMessageContent = formatDMMessageContent(data.data.content, userConversation.lastMessage.senderId, user.currentUser!.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||||
if (publicKey) {
|
if (publicKey) {
|
||||||
// Decrypt the last message
|
const decryptedJson = await decryptDm(userConversation.lastMessage, userConversation.lastMessage.senderId);
|
||||||
const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!);
|
|
||||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||||
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!);
|
lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser!.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to decrypt last message for user", userId, error);
|
console.error("Failed to decrypt last message for user", userId, error);
|
||||||
@@ -316,20 +376,30 @@ export function useDM() {
|
|||||||
|
|
||||||
// Update unread count and last message preview
|
// Update unread count and last message preview
|
||||||
try {
|
try {
|
||||||
|
let messageContent: string | null = null;
|
||||||
|
if (senderId === user.currentUser.id) {
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
|
||||||
|
const cached = plaintexts.get(envelope.id);
|
||||||
|
if (cached) {
|
||||||
|
messageContent = (JSON.parse(cached) as DmEncryptedJSON).data.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||||
if (publicKey) {
|
if (publicKey) {
|
||||||
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
|
const decryptedJson = await decryptDm(envelope, senderId);
|
||||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||||
const messageContent = decryptedData.data.content;
|
messageContent = decryptedData.data.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (messageContent !== null) {
|
||||||
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
||||||
|
|
||||||
setDmUsersState(prev => prev.map(u =>
|
setDmUsersState(prev => prev.map(u =>
|
||||||
u.id === otherUserId
|
u.id === otherUserId
|
||||||
? {
|
? {
|
||||||
...u,
|
...u,
|
||||||
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
|
unreadCount: senderId !== user.currentUser?.id ? u.unreadCount + 1 : u.unreadCount,
|
||||||
lastMessage: formattedMessage,
|
lastMessage: formattedMessage
|
||||||
publicKey
|
|
||||||
}
|
}
|
||||||
: u
|
: u
|
||||||
));
|
));
|
||||||
@@ -346,18 +416,29 @@ export function useDM() {
|
|||||||
}
|
}
|
||||||
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
|
const otherUserId = senderId === user.currentUser.id ? recipientId : senderId;
|
||||||
try {
|
try {
|
||||||
|
let messageContent: string | null = null;
|
||||||
|
if (senderId === user.currentUser.id) {
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(otherUserId);
|
||||||
|
const cached = plaintexts.get(id);
|
||||||
|
if (cached) {
|
||||||
|
messageContent = (JSON.parse(cached) as DmEncryptedJSON).data.content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!);
|
||||||
if (publicKey) {
|
if (publicKey) {
|
||||||
const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey);
|
const decryptedJson = await decryptDm(envelope, senderId);
|
||||||
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON;
|
||||||
const messageContent = decryptedData.data.content;
|
messageContent = decryptedData.data.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (messageContent !== null) {
|
||||||
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id);
|
||||||
setDmUsersState(prev => prev.map(u =>
|
setDmUsersState(prev => prev.map(u =>
|
||||||
u.id === otherUserId
|
u.id === otherUserId
|
||||||
? {
|
? {
|
||||||
...u,
|
...u,
|
||||||
lastMessage: formattedMessage,
|
lastMessage: formattedMessage
|
||||||
publicKey
|
|
||||||
}
|
}
|
||||||
: u
|
: u
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
import type { Message, User } from "@/core/types";
|
||||||
|
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||||
|
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||||
|
import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel";
|
||||||
|
import type { ChatState, ChatTabs, ActiveDM } from "./types";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
|
||||||
|
export interface ChatStateSlice {
|
||||||
|
chat: ChatState;
|
||||||
|
addMessage: (message: Message) => void;
|
||||||
|
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
|
||||||
|
removeMessage: (messageId: number) => void;
|
||||||
|
setCurrentChat: (chat: string) => void;
|
||||||
|
setActiveTab: (tab: ChatTabs) => void;
|
||||||
|
setDmUsers: (users: User[]) => void;
|
||||||
|
setActiveDm: (dm: ActiveDM | null) => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => void;
|
||||||
|
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||||
|
applyPendingPanel: () => void;
|
||||||
|
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||||
|
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createChatState: StateCreator<
|
||||||
|
ChatStateSlice & { user: { authToken: string | null } },
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ChatStateSlice
|
||||||
|
> = (set, get) => ({
|
||||||
|
chat: {
|
||||||
|
messages: [],
|
||||||
|
currentChat: "Общий чат",
|
||||||
|
activeTab: "chats",
|
||||||
|
dmUsers: [],
|
||||||
|
activeDm: null,
|
||||||
|
isSwitching: false,
|
||||||
|
setIsSwitching: (value: boolean) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
isSwitching: value
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
activePanel: null,
|
||||||
|
publicChatPanel: null,
|
||||||
|
dmPanel: null,
|
||||||
|
pendingPanel: null,
|
||||||
|
profileDialog: null,
|
||||||
|
call: {
|
||||||
|
isActive: false,
|
||||||
|
status: "ended",
|
||||||
|
startTime: null,
|
||||||
|
isMuted: false,
|
||||||
|
remoteUserId: null,
|
||||||
|
remoteUsername: null,
|
||||||
|
isInitiator: false,
|
||||||
|
isMinimized: false,
|
||||||
|
sessionKeyHash: null,
|
||||||
|
encryptionEmojis: [],
|
||||||
|
isVideoEnabled: false,
|
||||||
|
isRemoteVideoEnabled: false,
|
||||||
|
isSharingScreen: false,
|
||||||
|
isRemoteScreenSharing: false
|
||||||
|
},
|
||||||
|
onlineStatuses: new Map(),
|
||||||
|
typingUsers: new Map(),
|
||||||
|
dmTypingUsers: new Map()
|
||||||
|
},
|
||||||
|
addMessage: (message: Message) => set((state) => {
|
||||||
|
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||||
|
if (messageExists) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: [...state.chat.messages, message]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: state.chat.messages.map(msg =>
|
||||||
|
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
removeMessage: (messageId: number) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: state.chat.messages.filter(msg => msg.id !== messageId)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
clearMessages: () => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setCurrentChat: (chat: string) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
currentChat: chat
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActiveTab: (tab: ChatTabs) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activeTab: tab
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setDmUsers: (users: User[]) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
dmUsers: users
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActiveDm: (dm: ActiveDM | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activeDm: dm
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.chat.activePanel && state.chat.activePanel !== panel) {
|
||||||
|
state.chat.activePanel.deactivate();
|
||||||
|
}
|
||||||
|
return set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: panel
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: panel
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
applyPendingPanel: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.chat.activePanel) {
|
||||||
|
state.chat.activePanel.deactivate();
|
||||||
|
}
|
||||||
|
return set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||||
|
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||||
|
? (state.chat.pendingPanel as PublicChatPanel)
|
||||||
|
: state.chat.publicChatPanel,
|
||||||
|
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||||
|
? (state.chat.pendingPanel as DMPanel)
|
||||||
|
: state.chat.dmPanel,
|
||||||
|
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||||
|
pendingPanel: null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
switchToPublicChat: async (chatName: string) => {
|
||||||
|
const { chat } = get();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
chat.setIsSwitching(true);
|
||||||
|
|
||||||
|
let publicChatPanel = chat.publicChatPanel;
|
||||||
|
if (!publicChatPanel) {
|
||||||
|
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||||
|
} else {
|
||||||
|
publicChatPanel.setChatName(chatName);
|
||||||
|
publicChatPanel.setAuthToken(user.authToken);
|
||||||
|
publicChatPanel.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
await publicChatPanel.activate();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: publicChatPanel,
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
switchToDM: async (dmData: DMPanelData) => {
|
||||||
|
const { chat } = get();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
chat.setIsSwitching(true);
|
||||||
|
|
||||||
|
let dmPanel = chat.dmPanel;
|
||||||
|
if (!dmPanel) {
|
||||||
|
dmPanel = new DMPanel(user);
|
||||||
|
} else {
|
||||||
|
dmPanel.setAuthToken(user.authToken);
|
||||||
|
dmPanel.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
dmPanel.setDMData(dmData);
|
||||||
|
|
||||||
|
await dmPanel.activate();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: dmPanel,
|
||||||
|
activeDm: {
|
||||||
|
userId: dmData.userId,
|
||||||
|
username: dmData.username,
|
||||||
|
publicKey: dmData.publicKey
|
||||||
|
},
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
import type { Message, User } from "@/core/types";
|
||||||
|
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||||
|
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||||
|
import { DMPanel, type DMPanelData } from "../ui/right/panels/DMPanel";
|
||||||
|
import type { ChatState, ChatTabs, ActiveDM } from "./types";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
|
||||||
|
export interface ChatStateSlice {
|
||||||
|
chat: ChatState;
|
||||||
|
addMessage: (message: Message) => void;
|
||||||
|
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
|
||||||
|
removeMessage: (messageId: number) => void;
|
||||||
|
setCurrentChat: (chat: string) => void;
|
||||||
|
setActiveTab: (tab: ChatTabs) => void;
|
||||||
|
setDmUsers: (users: User[]) => void;
|
||||||
|
setActiveDm: (dm: ActiveDM | null) => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => void;
|
||||||
|
setPendingPanel: (panel: MessagePanel | null) => void;
|
||||||
|
applyPendingPanel: () => void;
|
||||||
|
switchToPublicChat: (chatName: string) => Promise<void>;
|
||||||
|
switchToDM: (dmData: DMPanelData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createChatState: StateCreator<
|
||||||
|
ChatStateSlice & { user: { authToken: string | null } },
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
ChatStateSlice
|
||||||
|
> = (set, get) => ({
|
||||||
|
chat: {
|
||||||
|
messages: [],
|
||||||
|
currentChat: "Общий чат",
|
||||||
|
activeTab: "chats",
|
||||||
|
dmUsers: [],
|
||||||
|
activeDm: null,
|
||||||
|
isSwitching: false,
|
||||||
|
setIsSwitching: (value: boolean) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
isSwitching: value
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
activePanel: null,
|
||||||
|
publicChatPanel: null,
|
||||||
|
dmPanel: null,
|
||||||
|
pendingPanel: null,
|
||||||
|
profileDialog: null,
|
||||||
|
call: {
|
||||||
|
isActive: false,
|
||||||
|
status: "ended",
|
||||||
|
startTime: null,
|
||||||
|
isMuted: false,
|
||||||
|
remoteUserId: null,
|
||||||
|
remoteUsername: null,
|
||||||
|
isInitiator: false,
|
||||||
|
isMinimized: false,
|
||||||
|
sessionKeyHash: null,
|
||||||
|
encryptionEmojis: [],
|
||||||
|
isVideoEnabled: false,
|
||||||
|
isRemoteVideoEnabled: false,
|
||||||
|
isSharingScreen: false,
|
||||||
|
isRemoteScreenSharing: false
|
||||||
|
},
|
||||||
|
onlineStatuses: new Map(),
|
||||||
|
typingUsers: new Map(),
|
||||||
|
dmTypingUsers: new Map()
|
||||||
|
},
|
||||||
|
addMessage: (message: Message) => set((state) => {
|
||||||
|
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
|
||||||
|
if (messageExists) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: [...state.chat.messages, message]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: state.chat.messages.map(msg =>
|
||||||
|
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
removeMessage: (messageId: number) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: state.chat.messages.filter(msg => msg.id !== messageId)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
clearMessages: () => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
messages: []
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setCurrentChat: (chat: string) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
currentChat: chat
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActiveTab: (tab: ChatTabs) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activeTab: tab
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setDmUsers: (users: User[]) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
dmUsers: users
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActiveDm: (dm: ActiveDM | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activeDm: dm
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
setActivePanel: (panel: MessagePanel | null) => {
|
||||||
|
const state = get();
|
||||||
|
if (state.chat.activePanel && state.chat.activePanel !== panel) {
|
||||||
|
state.chat.activePanel.deactivate();
|
||||||
|
}
|
||||||
|
return set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: panel
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: panel
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
applyPendingPanel: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.chat.activePanel) {
|
||||||
|
state.chat.activePanel.deactivate();
|
||||||
|
}
|
||||||
|
return set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
activePanel: state.chat.pendingPanel || state.chat.activePanel,
|
||||||
|
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
|
||||||
|
? (state.chat.pendingPanel as PublicChatPanel)
|
||||||
|
: state.chat.publicChatPanel,
|
||||||
|
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
|
||||||
|
? (state.chat.pendingPanel as DMPanel)
|
||||||
|
: state.chat.dmPanel,
|
||||||
|
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
|
||||||
|
pendingPanel: null
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
switchToPublicChat: async (chatName: string) => {
|
||||||
|
const { chat } = get();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
chat.setIsSwitching(true);
|
||||||
|
|
||||||
|
let publicChatPanel = chat.publicChatPanel;
|
||||||
|
if (!publicChatPanel) {
|
||||||
|
publicChatPanel = new PublicChatPanel(chatName, user);
|
||||||
|
} else {
|
||||||
|
publicChatPanel.setChatName(chatName);
|
||||||
|
publicChatPanel.setAuthToken(user.authToken);
|
||||||
|
publicChatPanel.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
await publicChatPanel.activate();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: publicChatPanel,
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
switchToDM: async (dmData: DMPanelData) => {
|
||||||
|
const { chat } = get();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
chat.setIsSwitching(true);
|
||||||
|
|
||||||
|
let dmPanel = chat.dmPanel;
|
||||||
|
if (!dmPanel) {
|
||||||
|
dmPanel = new DMPanel(user);
|
||||||
|
} else {
|
||||||
|
dmPanel.setAuthToken(user.authToken);
|
||||||
|
dmPanel.clearMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
dmPanel.setDMData(dmData);
|
||||||
|
|
||||||
|
await dmPanel.activate();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
pendingPanel: dmPanel,
|
||||||
|
activeDm: {
|
||||||
|
userId: dmData.userId,
|
||||||
|
username: dmData.username,
|
||||||
|
publicKey: dmData.publicKey
|
||||||
|
},
|
||||||
|
activeTab: "chats"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Message, User } from "@/core/types";
|
||||||
|
import { MessagePanel } from "../ui/right/panels/MessagePanel";
|
||||||
|
import { PublicChatPanel } from "../ui/right/panels/PublicChatPanel";
|
||||||
|
import { DMPanel } from "../ui/right/panels/DMPanel";
|
||||||
|
|
||||||
|
export type ChatTabs = "chats" | "channels" | "contacts";
|
||||||
|
|
||||||
|
export type CallStatus = "calling" | "connecting" | "active" | "ended";
|
||||||
|
|
||||||
|
export interface ProfileDialogData {
|
||||||
|
userId?: number;
|
||||||
|
username?: string;
|
||||||
|
display_name?: string;
|
||||||
|
profilePicture?: string;
|
||||||
|
bio?: string;
|
||||||
|
memberSince?: string;
|
||||||
|
online?: boolean;
|
||||||
|
isOwnProfile: boolean;
|
||||||
|
verified?: boolean;
|
||||||
|
suspended?: boolean;
|
||||||
|
suspension_reason?: string | null;
|
||||||
|
deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveDM {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
publicKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallState {
|
||||||
|
isActive: boolean;
|
||||||
|
status: CallStatus;
|
||||||
|
startTime: number | null;
|
||||||
|
isMuted: boolean;
|
||||||
|
remoteUserId: number | null;
|
||||||
|
remoteUsername: string | null;
|
||||||
|
isInitiator: boolean;
|
||||||
|
isMinimized: boolean;
|
||||||
|
sessionKeyHash: string | null;
|
||||||
|
encryptionEmojis: string[];
|
||||||
|
isVideoEnabled: boolean;
|
||||||
|
isRemoteVideoEnabled: boolean;
|
||||||
|
isSharingScreen: boolean;
|
||||||
|
isRemoteScreenSharing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatState {
|
||||||
|
messages: Message[];
|
||||||
|
currentChat: string;
|
||||||
|
activeTab: ChatTabs;
|
||||||
|
dmUsers: User[];
|
||||||
|
activeDm: ActiveDM | null;
|
||||||
|
isSwitching: boolean;
|
||||||
|
setIsSwitching: (value: boolean) => void;
|
||||||
|
activePanel: MessagePanel | null;
|
||||||
|
publicChatPanel: PublicChatPanel | null;
|
||||||
|
dmPanel: DMPanel | null;
|
||||||
|
pendingPanel?: MessagePanel | null;
|
||||||
|
call: CallState;
|
||||||
|
profileDialog: ProfileDialogData | null;
|
||||||
|
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
|
||||||
|
typingUsers: Map<number, string>; // userId -> username
|
||||||
|
dmTypingUsers: Map<number, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserState {
|
||||||
|
currentUser: User | null;
|
||||||
|
authToken: string | null;
|
||||||
|
isSuspended: boolean;
|
||||||
|
suspensionReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,11 +6,12 @@ import { parse } from "marked";
|
|||||||
import { escape as escapeHtml } from "he";
|
import { escape as escapeHtml } from "he";
|
||||||
import { useEffect, useState, useRef, useMemo } from "react";
|
import { useEffect, useState, useRef, useMemo } from "react";
|
||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
|
||||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||||
import { useUserStore } from "@/state/user";
|
import { useUserStore } from "@/state/user";
|
||||||
import { useProfileStore } from "@/state/profile";
|
import { useProfileStore } from "@/state/profile";
|
||||||
import { StatusBadge } from "@/core/components/StatusBadge";
|
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||||
|
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||||
|
import { removePadding } from "@/utils/crypto/obfuscation";
|
||||||
import { ub64 } from "@/utils/utils";
|
import { ub64 } from "@/utils/utils";
|
||||||
import { useImmer } from "use-immer";
|
import { useImmer } from "use-immer";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -212,7 +213,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
}, [message.files, isDm, decryptedFiles]);
|
}, [message.files, isDm, decryptedFiles]);
|
||||||
|
|
||||||
async function decryptFile(file: Attachment): Promise<string | null> {
|
async function decryptFile(file: Attachment): Promise<string | null> {
|
||||||
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) return null;
|
if (!file.encrypted || !isDm || !user.authToken || !dmEnvelope || !user.currentUser?.id) return null;
|
||||||
|
|
||||||
// Check if already decrypted
|
// Check if already decrypted
|
||||||
if (decryptedFiles.has(file.path)) {
|
if (decryptedFiles.has(file.path)) {
|
||||||
@@ -220,7 +221,6 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// no-op decrypt indicator removed from UI
|
|
||||||
// Fetch encrypted file
|
// Fetch encrypted file
|
||||||
const response = await fetch(file.path, {
|
const response = await fetch(file.path, {
|
||||||
headers: api.user.auth.getAuthHeaders(user.authToken!)
|
headers: api.user.auth.getAuthHeaders(user.authToken!)
|
||||||
@@ -229,19 +229,36 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD
|
|||||||
|
|
||||||
const encryptedData = await response.arrayBuffer();
|
const encryptedData = await response.arrayBuffer();
|
||||||
|
|
||||||
// Get current user's keys
|
// Decrypt the master key using Signal Protocol
|
||||||
const keys = api.user.auth.getCurrentKeys();
|
const signalService = new SignalProtocolService(user.currentUser.id.toString());
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
const senderId = dmEnvelope.senderId;
|
||||||
|
|
||||||
// Derive shared secret with the recipient's public key
|
// Remove padding from wrappedMk (backward compatible)
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(dmRecipientPublicKey));
|
let wrappedMkStr: string;
|
||||||
|
try {
|
||||||
|
wrappedMkStr = removePadding(dmEnvelope.wrappedMk);
|
||||||
|
} catch {
|
||||||
|
// If padding removal fails, assume it's an old message without padding
|
||||||
|
wrappedMkStr = dmEnvelope.wrappedMk;
|
||||||
|
}
|
||||||
|
|
||||||
// Derive wrapping key using the salt from the DM envelope
|
// Parse wrappedMk - it's a JSON string containing Signal Protocol encrypted data
|
||||||
const wkRaw = await deriveWrappingKey(shared, ub64(dmEnvelope.salt), new Uint8Array([1]));
|
let mk: Uint8Array;
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
try {
|
||||||
|
const encryptedMk = JSON.parse(wrappedMkStr);
|
||||||
// Unwrap the message key
|
if (encryptedMk.type && encryptedMk.body) {
|
||||||
const mk = await aesGcmDecrypt(wk, ub64(dmEnvelope.iv2), ub64(dmEnvelope.wrappedMk));
|
// Signal Protocol encrypted
|
||||||
|
const mkBase64 = await signalService.decryptMessage(senderId, encryptedMk);
|
||||||
|
mk = new Uint8Array(
|
||||||
|
atob(mkBase64).split("").map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid Signal Protocol message format");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to decrypt master key with Signal Protocol:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt the file using the message key
|
// Decrypt the file using the message key
|
||||||
const iv = new Uint8Array(encryptedData, 0, 12);
|
const iv = new Uint8Array(encryptedData, 0, 12);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { MessagePanel } from "./MessagePanel";
|
import { MessagePanel } from "./MessagePanel";
|
||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
|
import { decryptDm, sendDMViaWebSocket, sendDmWithFiles } from "@/core/api/dm";
|
||||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||||
import type { UserState, ProfileDialogData } from "@/state/types";
|
import type { UserState, ProfileDialogData } from "@/state/types";
|
||||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
import { typingManager } from "@/core/typingManager";
|
import { typingManager } from "@/core/typingManager";
|
||||||
|
import { SignalProtocolService } from "@/utils/crypto/signalProtocol";
|
||||||
|
|
||||||
export interface DMPanelData {
|
export interface DMPanelData {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -17,11 +19,16 @@ export interface DMPanelData {
|
|||||||
export class DMPanel extends MessagePanel {
|
export class DMPanel extends MessagePanel {
|
||||||
public dmData: DMPanelData | null = null;
|
public dmData: DMPanelData | null = null;
|
||||||
private messagesLoaded: boolean = false;
|
private messagesLoaded: boolean = false;
|
||||||
|
private signalService: SignalProtocolService | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
user: UserState
|
user: UserState
|
||||||
) {
|
) {
|
||||||
super("dm", user);
|
super("dm", user);
|
||||||
|
// Initialize Signal Protocol service if user is available
|
||||||
|
if (user.currentUser?.id) {
|
||||||
|
this.signalService = new SignalProtocolService(user.currentUser.id.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isDm(): boolean {
|
isDm(): boolean {
|
||||||
@@ -52,10 +59,41 @@ export class DMPanel extends MessagePanel {
|
|||||||
clearMessages(): void {
|
clearMessages(): void {
|
||||||
super.clearMessages();
|
super.clearMessages();
|
||||||
this.messagesLoaded = false;
|
this.messagesLoaded = false;
|
||||||
|
this.processedMessageIds.clear();
|
||||||
|
this.failedDecryptionIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[], plaintextOverride?: string) {
|
||||||
|
// Check if this is a message sent by the current user
|
||||||
|
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
|
||||||
|
|
||||||
|
let plaintext: string;
|
||||||
|
if (isSentByUs) {
|
||||||
|
// Can't decrypt our own sent messages in Signal Protocol
|
||||||
|
// The plaintext should be passed in from loadMessages (fetched from server)
|
||||||
|
if (plaintextOverride) {
|
||||||
|
plaintext = plaintextOverride;
|
||||||
|
} else {
|
||||||
|
// Try to fetch from server as fallback
|
||||||
|
try {
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData!.userId);
|
||||||
|
const cached = plaintexts.get(env.id);
|
||||||
|
if (cached) {
|
||||||
|
plaintext = cached;
|
||||||
|
} else {
|
||||||
|
// Not on server - skip this message
|
||||||
|
throw new Error("Cannot decrypt own sent message - plaintext not available on server");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Cannot decrypt own sent message - plaintext must be fetched from server first");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Decrypt incoming messages
|
||||||
|
plaintext = await decryptDm(env, env.senderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
|
||||||
const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey);
|
|
||||||
const username = formatDMUsername(
|
const username = formatDMUsername(
|
||||||
env.senderId,
|
env.senderId,
|
||||||
env.recipientId,
|
env.recipientId,
|
||||||
@@ -103,6 +141,35 @@ export class DMPanel extends MessagePanel {
|
|||||||
|
|
||||||
this.setLoading(true);
|
this.setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
// Wait for session restoration to complete (if in progress)
|
||||||
|
const { waitForSessionRestore } = await import("@/utils/crypto/sessionRestoreState");
|
||||||
|
await waitForSessionRestore();
|
||||||
|
console.log(`[DMPanel] Session restoration complete, proceeding with message load for user ${this.dmData.userId}`);
|
||||||
|
|
||||||
|
// Ensure Signal Protocol session is established before fetching messages
|
||||||
|
if (!this.signalService && this.currentUser.currentUser?.id) {
|
||||||
|
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
|
||||||
|
}
|
||||||
|
if (this.signalService) {
|
||||||
|
const hasSession = await this.signalService.hasSession(this.dmData.userId);
|
||||||
|
if (!hasSession) {
|
||||||
|
try {
|
||||||
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
|
||||||
|
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
|
||||||
|
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during history load.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during history load:`, error);
|
||||||
|
// Continue loading history, but decryption will likely fail for new messages
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[DMPanel] Signal Protocol session exists for user ${this.dmData.userId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch encrypted plaintexts from server for sent messages
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
|
||||||
|
|
||||||
const limit = this.calculateMessageLimit();
|
const limit = this.calculateMessageLimit();
|
||||||
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
|
const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit);
|
||||||
const decryptedMessages: Message[] = [];
|
const decryptedMessages: Message[] = [];
|
||||||
@@ -110,19 +177,82 @@ export class DMPanel extends MessagePanel {
|
|||||||
|
|
||||||
for (const env of messages) {
|
for (const env of messages) {
|
||||||
try {
|
try {
|
||||||
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
// Mark as processed to prevent duplicates
|
||||||
|
if (env.id) {
|
||||||
|
this.processedMessageIds.add(env.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For sent messages, use plaintext from server
|
||||||
|
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
|
||||||
|
let dmMsg: Message;
|
||||||
|
if (isSentByUs) {
|
||||||
|
const cachedPlaintext = plaintexts.get(env.id);
|
||||||
|
if (!cachedPlaintext) {
|
||||||
|
// Not on server - skip this message
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Parse the plaintext as if it came from parseTextPayload
|
||||||
|
const username = formatDMUsername(
|
||||||
|
env.senderId,
|
||||||
|
env.recipientId,
|
||||||
|
this.currentUser.currentUser?.id!,
|
||||||
|
this.dmData!.username
|
||||||
|
);
|
||||||
|
let content = cachedPlaintext;
|
||||||
|
let reply_to_id: number | undefined = undefined;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
|
||||||
|
if (obj && obj.type === "text" && obj.data) {
|
||||||
|
content = obj.data.content;
|
||||||
|
reply_to_id = Number(obj.data.reply_to_id) || undefined;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
dmMsg = {
|
||||||
|
id: env.id,
|
||||||
|
user_id: env.senderId,
|
||||||
|
content: content,
|
||||||
|
username: username,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
is_read: false,
|
||||||
|
is_edited: false,
|
||||||
|
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||||
|
reactions: env.reactions || [],
|
||||||
|
runtimeData: {
|
||||||
|
dmEnvelope: env
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (reply_to_id) {
|
||||||
|
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
|
||||||
|
if (referenced) dmMsg.reply_to = referenced;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||||
|
}
|
||||||
decryptedMessages.push(dmMsg);
|
decryptedMessages.push(dmMsg);
|
||||||
|
|
||||||
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
||||||
maxIncomingId = env.id;
|
maxIncomingId = env.id;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error decrypting message:", error);
|
// Log warning with deduplication to avoid console spam
|
||||||
|
if (env.id && !this.failedDecryptionIds.has(env.id)) {
|
||||||
|
this.failedDecryptionIds.add(env.id);
|
||||||
|
console.warn(`Failed to decrypt DM ${env.id}:`, error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
// Remove from processed set if decryption failed
|
||||||
|
if (env.id) {
|
||||||
|
this.processedMessageIds.delete(env.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only clear and replace if we actually decrypted something
|
||||||
|
if (decryptedMessages.length > 0) {
|
||||||
this.clearMessages();
|
this.clearMessages();
|
||||||
decryptedMessages.forEach(msg => this.addMessage(msg));
|
decryptedMessages.forEach(msg => this.addMessage(msg));
|
||||||
|
} else {
|
||||||
|
console.warn("[DMPanel] No messages decrypted; keeping existing messages to avoid empty state after reload.");
|
||||||
|
}
|
||||||
this.setHasMoreMessages(has_more);
|
this.setHasMoreMessages(has_more);
|
||||||
|
|
||||||
// Update last read ID
|
// Update last read ID
|
||||||
@@ -149,6 +279,28 @@ export class DMPanel extends MessagePanel {
|
|||||||
|
|
||||||
this.setLoadingMore(true);
|
this.setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
|
// Ensure Signal Protocol session is established before fetching messages
|
||||||
|
if (!this.signalService && this.currentUser.currentUser?.id) {
|
||||||
|
this.signalService = new SignalProtocolService(this.currentUser.currentUser.id.toString());
|
||||||
|
}
|
||||||
|
if (this.signalService) {
|
||||||
|
const hasSession = await this.signalService.hasSession(this.dmData.userId);
|
||||||
|
if (!hasSession) {
|
||||||
|
try {
|
||||||
|
const bundle = await api.crypto.prekeys.fetchPreKeyBundle(this.dmData.userId, this.currentUser.authToken);
|
||||||
|
await this.signalService.processPreKeyBundle(this.dmData.userId, bundle);
|
||||||
|
console.log(`Established new Signal Protocol session for user ${this.dmData.userId} during more history load.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to establish Signal Protocol session for user ${this.dmData.userId} during more history load:`, error);
|
||||||
|
// Continue loading history, but decryption will likely fail for new messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch encrypted plaintexts from server for sent messages
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
|
||||||
|
|
||||||
const limit = this.calculateMessageLimit();
|
const limit = this.calculateMessageLimit();
|
||||||
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
|
const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages(
|
||||||
this.dmData.userId,
|
this.dmData.userId,
|
||||||
@@ -161,10 +313,60 @@ export class DMPanel extends MessagePanel {
|
|||||||
const decryptedMessages: Message[] = [];
|
const decryptedMessages: Message[] = [];
|
||||||
for (const env of newEnvelopes) {
|
for (const env of newEnvelopes) {
|
||||||
try {
|
try {
|
||||||
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
// Mark as processed to prevent duplicates
|
||||||
|
if (env.id) {
|
||||||
|
this.processedMessageIds.add(env.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For sent messages, use plaintext from server
|
||||||
|
const isSentByUs = env.senderId === this.currentUser.currentUser?.id;
|
||||||
|
let dmMsg: Message;
|
||||||
|
if (isSentByUs) {
|
||||||
|
const cachedPlaintext = plaintexts.get(env.id);
|
||||||
|
if (!cachedPlaintext) {
|
||||||
|
// Not on server - skip this message
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Parse the plaintext as if it came from parseTextPayload
|
||||||
|
const username = formatDMUsername(
|
||||||
|
env.senderId,
|
||||||
|
env.recipientId,
|
||||||
|
this.currentUser.currentUser?.id!,
|
||||||
|
this.dmData!.username
|
||||||
|
);
|
||||||
|
let content = cachedPlaintext;
|
||||||
|
let reply_to_id: number | undefined = undefined;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(cachedPlaintext) as DmEncryptedJSON;
|
||||||
|
if (obj && obj.type === "text" && obj.data) {
|
||||||
|
content = obj.data.content;
|
||||||
|
reply_to_id = Number(obj.data.reply_to_id) || undefined;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
dmMsg = {
|
||||||
|
id: env.id,
|
||||||
|
user_id: env.senderId,
|
||||||
|
content: content,
|
||||||
|
username: username,
|
||||||
|
timestamp: env.timestamp,
|
||||||
|
is_read: false,
|
||||||
|
is_edited: false,
|
||||||
|
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||||
|
reactions: env.reactions || [],
|
||||||
|
runtimeData: {
|
||||||
|
dmEnvelope: env
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (reply_to_id) {
|
||||||
|
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
|
||||||
|
if (referenced) dmMsg.reply_to = referenced;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||||
|
}
|
||||||
decryptedMessages.push(dmMsg);
|
decryptedMessages.push(dmMsg);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error decrypting message:", error);
|
// Silently skip messages that can't be decrypted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,22 +395,31 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
const json = JSON.stringify(payload);
|
const json = JSON.stringify(payload);
|
||||||
|
|
||||||
|
try {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
await api.chats.dm.send(
|
await sendDMViaWebSocket(
|
||||||
this.dmData.userId,
|
this.dmData.userId,
|
||||||
this.dmData.publicKey,
|
|
||||||
json,
|
json,
|
||||||
this.currentUser.authToken
|
this.currentUser.authToken
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await api.chats.dm.sendWithFiles(
|
await sendDmWithFiles(
|
||||||
this.dmData.userId,
|
this.dmData.userId,
|
||||||
this.dmData.publicKey,
|
|
||||||
json,
|
json,
|
||||||
files,
|
files,
|
||||||
this.currentUser.authToken
|
this.currentUser.authToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send DM:", error);
|
||||||
|
|
||||||
|
// Check if it's a prekey exhaustion error
|
||||||
|
const { PrekeyExhaustedError } = await import("@/core/api/crypto/prekeys");
|
||||||
|
if (error instanceof PrekeyExhaustedError) {
|
||||||
|
const { alert } = await import("@/core/components/AlertDialog");
|
||||||
|
await alert("Cannot Send Message: The recipient's encryption keys are temporarily unavailable. They need to come online to refresh their keys. This ensures maximum privacy and security.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set DM conversation data
|
// Set DM conversation data
|
||||||
@@ -224,29 +435,103 @@ export class DMPanel extends MessagePanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Track processed message IDs to prevent duplicates
|
||||||
|
private processedMessageIds: Set<number> = new Set();
|
||||||
|
private failedDecryptionIds: Set<number> = new Set(); // Track messages that failed decryption to avoid spam
|
||||||
|
|
||||||
// Handle incoming WebSocket DM messages
|
// Handle incoming WebSocket DM messages
|
||||||
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
|
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
|
||||||
|
// Only process actual DM messages, not typing indicators or other events
|
||||||
if (response.type === "dmNew" && this.dmData) {
|
if (response.type === "dmNew" && this.dmData) {
|
||||||
const envelope = response.data;
|
const envelope = response.data;
|
||||||
|
|
||||||
|
// Validate envelope has required fields
|
||||||
|
if (!envelope || !envelope.ciphertext || !envelope.senderId || !envelope.id) {
|
||||||
|
console.warn("Invalid DM envelope received, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if we've already processed this message
|
||||||
|
if (this.processedMessageIds.has(envelope.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If this is for the active DM conversation
|
// If this is for the active DM conversation
|
||||||
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
|
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
|
||||||
try {
|
try {
|
||||||
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
|
// Mark as processed before attempting decryption
|
||||||
|
this.processedMessageIds.add(envelope.id);
|
||||||
|
|
||||||
// Check if this is a confirmation of a message we sent
|
// Check if this is a confirmation of a message we sent
|
||||||
const isOurMessage = envelope.senderId !== this.dmData.userId;
|
const isOurMessage = envelope.senderId === this.currentUser.currentUser?.id;
|
||||||
|
|
||||||
|
let dmMsg: Message;
|
||||||
if (isOurMessage) {
|
if (isOurMessage) {
|
||||||
// This is our message being confirmed, find the temp message and replace it
|
// For sent messages, fetch plaintext from server
|
||||||
|
const { fetchMessagePlaintextsForRecipient } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
const plaintexts = await fetchMessagePlaintextsForRecipient(this.dmData.userId);
|
||||||
|
const cachedPlaintext = plaintexts.get(envelope.id);
|
||||||
|
if (cachedPlaintext) {
|
||||||
|
// Parse the plaintext and create message
|
||||||
|
dmMsg = await this.parseTextPayload(envelope, this.getMessages(), cachedPlaintext);
|
||||||
|
} else {
|
||||||
|
// Plaintext not available yet - this might be a new message confirmation
|
||||||
|
// Try to get it from temp message content
|
||||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||||
|
let tempMsgContent: string | null = null;
|
||||||
for (const tempMsg of tempMessages) {
|
for (const tempMsg of tempMessages) {
|
||||||
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) {
|
if (tempMsg.runtimeData?.sendingState?.retryData?.content) {
|
||||||
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg);
|
tempMsgContent = tempMsg.content;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tempMsgContent) {
|
||||||
|
dmMsg = await this.parseTextPayload(envelope, this.getMessages(), tempMsgContent);
|
||||||
|
} else {
|
||||||
|
// Can't display without plaintext - skip
|
||||||
|
console.warn(`Cannot display sent message ${envelope.id} - plaintext not available`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Incoming message - decrypt normally
|
||||||
|
dmMsg = await this.parseTextPayload(envelope, this.getMessages());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOurMessage) {
|
||||||
|
// This is our message being confirmed, find the temp message and replace it
|
||||||
|
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||||
|
let tempMsgContent: string | null = null;
|
||||||
|
for (const tempMsg of tempMessages) {
|
||||||
|
if ((tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content ||
|
||||||
|
tempMsg.content === dmMsg.content) && tempMsg.runtimeData?.sendingState?.tempId) {
|
||||||
|
tempMsgContent = tempMsg.content; // Get plaintext from temp message
|
||||||
|
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId, dmMsg);
|
||||||
|
|
||||||
|
// Upload the plaintext to server (encrypted) so we can display it in history
|
||||||
|
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
if (tempMsgContent) {
|
||||||
|
await uploadMessagePlaintext(this.dmData.userId, envelope.id, tempMsgContent);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didn't find a temp message, try to upload from dmMsg content
|
||||||
|
// (this might happen if the page was reloaded)
|
||||||
|
if (!tempMsgContent && dmMsg.content) {
|
||||||
|
const { uploadMessagePlaintext } = await import("@/utils/crypto/messagePlaintextSync");
|
||||||
|
await uploadMessagePlaintext(this.dmData.userId, envelope.id, dmMsg.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the message to the chat
|
||||||
|
this.addMessage(dmMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming message - add to chat
|
||||||
|
this.addMessage(dmMsg);
|
||||||
|
|
||||||
this.addMessage(dmMsg);
|
this.addMessage(dmMsg);
|
||||||
|
|
||||||
// Update last read if it's from the other user
|
// Update last read if it's from the other user
|
||||||
@@ -254,19 +539,27 @@ export class DMPanel extends MessagePanel {
|
|||||||
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
|
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to decrypt incoming DM:", error);
|
// Only log each failed message once to avoid console spam
|
||||||
|
if (envelope.id && !this.failedDecryptionIds.has(envelope.id)) {
|
||||||
|
this.failedDecryptionIds.add(envelope.id);
|
||||||
|
console.warn(`Failed to decrypt DM ${envelope.id}:`, error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
// Remove from processed set so we can retry if needed
|
||||||
|
if (envelope.id) {
|
||||||
|
this.processedMessageIds.delete(envelope.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (response.type === "dmEdited" && this.dmData) {
|
if (response.type === "dmEdited" && this.dmData) {
|
||||||
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
const { id, senderId, recipientId, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||||
try {
|
try {
|
||||||
// Decrypt new content in-place
|
// Decrypt new content in-place
|
||||||
const plaintext = await api.chats.dm.decrypt(
|
const plaintext = await decryptDm(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
senderId: 0,
|
senderId,
|
||||||
recipientId: 0,
|
recipientId,
|
||||||
iv,
|
iv,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
salt,
|
salt,
|
||||||
@@ -274,7 +567,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
wrappedMk,
|
wrappedMk,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
},
|
},
|
||||||
this.dmData.publicKey
|
senderId
|
||||||
);
|
);
|
||||||
let content = plaintext;
|
let content = plaintext;
|
||||||
let files: Message["files"] | undefined = undefined;
|
let files: Message["files"] | undefined = undefined;
|
||||||
@@ -311,6 +604,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
this.dmData = null;
|
this.dmData = null;
|
||||||
this.messagesLoaded = false;
|
this.messagesLoaded = false;
|
||||||
this.clearMessages();
|
this.clearMessages();
|
||||||
|
this.failedDecryptionIds.clear(); // Clear failed decryption tracking
|
||||||
this.updateState({
|
this.updateState({
|
||||||
id: "dm",
|
id: "dm",
|
||||||
title: "Select a user",
|
title: "Select a user",
|
||||||
@@ -379,7 +673,7 @@ export class DMPanel extends MessagePanel {
|
|||||||
reply_to_id: msg?.reply_to?.id ?? undefined
|
reply_to_id: msg?.reply_to?.id ?? undefined
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
api.chats.dm.edit(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
api.chats.dm.edit(messageId, this.dmData.userId, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
||||||
console.error("Failed to edit DM:", e);
|
console.error("Failed to edit DM:", e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { isElectron } from "@/core/electron/electron";
|
|||||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||||
import { typingManager } from "@/core/typingManager";
|
import { typingManager } from "@/core/typingManager";
|
||||||
import type { UserState } from "./types";
|
import type { UserState } from "./types";
|
||||||
|
import { clearSessionSync } from "@/utils/crypto/sessionSync";
|
||||||
|
import { clearMessagePlaintextSync } from "@/utils/crypto/messagePlaintextSync";
|
||||||
|
import { resetSessionRestoreState } from "@/utils/crypto/sessionRestoreState";
|
||||||
|
|
||||||
interface UserStore {
|
interface UserStore {
|
||||||
user: UserState;
|
user: UserState;
|
||||||
@@ -50,8 +53,9 @@ export const useUserStore = create<UserStore>((set) => ({
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem('authToken');
|
localStorage.removeItem('authToken');
|
||||||
localStorage.removeItem('currentUser');
|
localStorage.removeItem('currentUser');
|
||||||
|
sessionStorage.removeItem('sessionPassword');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear localStorage:', error);
|
console.error('Failed to clear storage:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
onlineStatusManager.setAuthToken(null);
|
onlineStatusManager.setAuthToken(null);
|
||||||
@@ -59,6 +63,17 @@ export const useUserStore = create<UserStore>((set) => ({
|
|||||||
onlineStatusManager.cleanup();
|
onlineStatusManager.cleanup();
|
||||||
typingManager.cleanup();
|
typingManager.cleanup();
|
||||||
|
|
||||||
|
// Clear session sync
|
||||||
|
clearSessionSync();
|
||||||
|
clearMessagePlaintextSync();
|
||||||
|
|
||||||
|
// Reset session restore state
|
||||||
|
try {
|
||||||
|
resetSessionRestoreState();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset session restore state:", error);
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
user: {
|
user: {
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
@@ -104,6 +119,58 @@ export const useUserStore = create<UserStore>((set) => ({
|
|||||||
onlineStatusManager.setAuthToken(token);
|
onlineStatusManager.setAuthToken(token);
|
||||||
typingManager.setAuthToken(token);
|
typingManager.setAuthToken(token);
|
||||||
|
|
||||||
|
// Initialize Signal Protocol after restoring user (non-blocking)
|
||||||
|
// Note: We can't restore sessions without password, but we can initialize Signal Protocol
|
||||||
|
if (user.id) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { SignalProtocolService } = await import("@/utils/crypto/signalProtocol");
|
||||||
|
const { uploadAllPreKeys } = await import("@/core/api/crypto/prekeys");
|
||||||
|
const { getStoredSessionKey } = await import("@/utils/crypto/sessionKeyStorage");
|
||||||
|
const { restoreSessionsFromServer } = await import("@/utils/crypto/sessionSync");
|
||||||
|
|
||||||
|
console.log("[RestoreFromStorage] Starting Signal Protocol setup...");
|
||||||
|
|
||||||
|
const signalService = new SignalProtocolService(user.id.toString());
|
||||||
|
await signalService.initialize();
|
||||||
|
console.log("[RestoreFromStorage] Signal Protocol initialized");
|
||||||
|
|
||||||
|
// Check if we have a stored session key (derived from password)
|
||||||
|
const storedKey = getStoredSessionKey(user.id.toString());
|
||||||
|
if (storedKey) {
|
||||||
|
console.log("[RestoreFromStorage] Stored session key found, restoring sessions from server...");
|
||||||
|
// We can restore sessions using the stored key (password not needed)
|
||||||
|
const { setRestoringSessions } = await import("@/utils/crypto/sessionRestoreState");
|
||||||
|
const restorePromise = restoreSessionsFromServer(user.id.toString(), null, token);
|
||||||
|
setRestoringSessions(restorePromise);
|
||||||
|
try {
|
||||||
|
await restorePromise;
|
||||||
|
console.log("[RestoreFromStorage] Sessions restored from server using stored key");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RestoreFromStorage] Failed to restore sessions:", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[RestoreFromStorage] No stored session key - user needs to log in to derive key");
|
||||||
|
// Mark restore as complete even if we couldn't restore (to avoid blocking message loading)
|
||||||
|
const { setRestoringSessions } = await import("@/utils/crypto/sessionRestoreState");
|
||||||
|
setRestoringSessions(Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-upload prekeys to ensure they are fresh
|
||||||
|
try {
|
||||||
|
const baseBundle = await signalService.getBaseBundle();
|
||||||
|
const prekeys = await signalService.getAllPreKeys();
|
||||||
|
await uploadAllPreKeys(baseBundle, prekeys, token);
|
||||||
|
console.log(`[RestoreFromStorage] Uploaded ${prekeys.length} prekeys to server`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RestoreFromStorage] Failed to upload prekeys:", error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[RestoreFromStorage] Signal Protocol setup failed:", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
// Ping will be sent automatically on WebSocket reconnect
|
// Ping will be sent automatically on WebSocket reconnect
|
||||||
// No need to send here to avoid duplicate pings
|
// No need to send here to avoid duplicate pings
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ export async function importPassword(password: string): Promise<CryptoKey> {
|
|||||||
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
|
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
|
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000, extractable = false): Promise<CryptoKey> {
|
||||||
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||||
return crypto.subtle.deriveKey(
|
return crypto.subtle.deriveKey(
|
||||||
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
|
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
|
||||||
passwordKey,
|
passwordKey,
|
||||||
{ name: "AES-GCM", length: 256 },
|
{ name: "AES-GCM", length: 256 },
|
||||||
false,
|
extractable,
|
||||||
["encrypt", "decrypt"]
|
["encrypt", "decrypt"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Message cache for storing sent message plaintexts
|
||||||
|
* Since Signal Protocol doesn't allow decrypting your own sent messages,
|
||||||
|
* we store the plaintext locally and optionally sync to server
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DB_NAME = "message_cache_db";
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_NAME = "sent_messages";
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
// Key: [userId, messageId], Value: { plaintext, timestamp }
|
||||||
|
const store = db.createObjectStore(STORE_NAME, { keyPath: ["userId", "messageId"] });
|
||||||
|
store.createIndex("userId", "userId", { unique: false });
|
||||||
|
store.createIndex("messageId", "messageId", { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStore(mode: IDBTransactionMode = "readonly"): Promise<IDBObjectStore> {
|
||||||
|
const db = await openDB();
|
||||||
|
const tx = db.transaction([STORE_NAME], mode);
|
||||||
|
return tx.objectStore(STORE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedMessage {
|
||||||
|
userId: number;
|
||||||
|
messageId: number;
|
||||||
|
plaintext: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a sent message's plaintext in the cache
|
||||||
|
*/
|
||||||
|
export async function cacheSentMessage(userId: number, messageId: number, plaintext: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getStore("readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId,
|
||||||
|
messageId,
|
||||||
|
plaintext,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to cache sent message:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a sent message's plaintext from the cache
|
||||||
|
*/
|
||||||
|
export async function getCachedMessage(userId: number, messageId: number): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const store = await getStore();
|
||||||
|
const result = await new Promise<CachedMessage | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get([userId, messageId]);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
return result?.plaintext || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to get cached message:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all cached messages for a user
|
||||||
|
*/
|
||||||
|
export async function getAllCachedMessages(userId: number): Promise<Map<number, string>> {
|
||||||
|
const cache = new Map<number, string>();
|
||||||
|
try {
|
||||||
|
const store = await getStore();
|
||||||
|
const index = store.index("userId");
|
||||||
|
const result = await new Promise<CachedMessage[]>((resolve, reject) => {
|
||||||
|
const request = index.getAll(userId);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
result.forEach(msg => {
|
||||||
|
cache.set(msg.messageId, msg.plaintext);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to get all cached messages:", error);
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached messages for a user (e.g., on logout)
|
||||||
|
*/
|
||||||
|
export async function clearCachedMessages(userId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getStore("readwrite");
|
||||||
|
const index = store.index("userId");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = index.openCursor(IDBKeyRange.only(userId));
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result;
|
||||||
|
if (cursor) {
|
||||||
|
cursor.delete();
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to clear cached messages:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Functions for encrypting/decrypting sent message plaintexts
|
||||||
|
* Uses password-derived key (same as session encryption)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encryptSessionWithPassword, decryptSessionWithPassword, encodeSessionBlob, decodeSessionBlob } from "./sessionEncryption";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt message plaintext using password-derived key
|
||||||
|
*/
|
||||||
|
export async function encryptMessagePlaintext(password: string | null, userId: string, plaintext: string): Promise<string> {
|
||||||
|
const encrypted = await encryptSessionWithPassword(password, userId, plaintext);
|
||||||
|
return encodeSessionBlob(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt message plaintext using password-derived key
|
||||||
|
*/
|
||||||
|
export async function decryptMessagePlaintext(password: string | null, userId: string, encryptedData: string): Promise<string> {
|
||||||
|
const blob = decodeSessionBlob(encryptedData);
|
||||||
|
return await decryptSessionWithPassword(password, userId, blob);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Service for syncing sent message plaintexts with the server
|
||||||
|
* Plaintexts are encrypted with password-derived key and stored on server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { encryptMessagePlaintext, decryptMessagePlaintext } from "./messagePlaintextEncryption";
|
||||||
|
import { uploadMessagePlaintexts, fetchMessagePlaintexts } from "@/core/api/crypto/messagePlaintexts";
|
||||||
|
|
||||||
|
// Global state for message plaintext sync
|
||||||
|
let syncPassword: string | null = null;
|
||||||
|
let syncToken: string | null = null;
|
||||||
|
let syncUserId: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize message plaintext sync - stores password and userId for encryption
|
||||||
|
* Called after login when password is available
|
||||||
|
*/
|
||||||
|
export function initializeMessagePlaintextSync(userId: string, password: string, token: string): void {
|
||||||
|
console.log("Initializing message plaintext sync for user", userId);
|
||||||
|
syncUserId = userId;
|
||||||
|
syncPassword = password;
|
||||||
|
syncToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear message plaintext sync - called on logout
|
||||||
|
*/
|
||||||
|
export function clearMessagePlaintextSync(): void {
|
||||||
|
console.log("Clearing message plaintext sync state");
|
||||||
|
syncUserId = null;
|
||||||
|
syncPassword = null;
|
||||||
|
syncToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a sent message's plaintext to the server (encrypted)
|
||||||
|
* Called when a message is sent and confirmed
|
||||||
|
*/
|
||||||
|
export async function uploadMessagePlaintext(
|
||||||
|
messageId: number,
|
||||||
|
recipientId: number,
|
||||||
|
plaintext: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!syncPassword || !syncToken || !syncUserId) {
|
||||||
|
console.warn("Message plaintext sync not initialized (missing password/token/userId)");
|
||||||
|
return; // Not initialized yet
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Encrypting plaintext for message ${messageId}...`);
|
||||||
|
// Use stored key if available, otherwise use password to derive it
|
||||||
|
const encryptedData = await encryptMessagePlaintext(syncPassword, syncUserId, plaintext);
|
||||||
|
|
||||||
|
console.log(`Uploading plaintext for message ${messageId} to server...`);
|
||||||
|
await uploadMessagePlaintexts([
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
recipientId,
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
], syncToken);
|
||||||
|
console.log(`Successfully uploaded plaintext for message ${messageId} to server`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload message plaintext to server:", error);
|
||||||
|
// Don't throw - message is already sent, plaintext upload failure shouldn't break anything
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and decrypt message plaintexts from the server
|
||||||
|
* Called when loading message history
|
||||||
|
*/
|
||||||
|
export async function fetchMessagePlaintextsForRecipient(
|
||||||
|
recipientId: number
|
||||||
|
): Promise<Map<number, string>> {
|
||||||
|
const plaintexts = new Map<number, string>();
|
||||||
|
|
||||||
|
// Try to get token and userId from global state if sync isn't initialized
|
||||||
|
let token = syncToken;
|
||||||
|
let userId = syncUserId;
|
||||||
|
let password = syncPassword;
|
||||||
|
|
||||||
|
if (!token || !userId) {
|
||||||
|
// Fallback: try to get from user store
|
||||||
|
try {
|
||||||
|
const { useUserStore } = await import("@/state/user");
|
||||||
|
const userState = useUserStore.getState().user;
|
||||||
|
if (userState.authToken && userState.currentUser?.id) {
|
||||||
|
token = userState.authToken;
|
||||||
|
userId = userState.currentUser.id.toString();
|
||||||
|
console.log(`[MessagePlaintextSync] Using token/userId from user store (sync not initialized)`);
|
||||||
|
} else {
|
||||||
|
console.warn("Message plaintext sync not initialized (missing token/userId)");
|
||||||
|
return plaintexts; // Return empty map if not initialized
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Message plaintext sync not initialized (missing token/userId)");
|
||||||
|
return plaintexts; // Return empty map if not initialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password can be null - decryptMessagePlaintext will use stored session key if password is null
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Fetching encrypted plaintexts for recipient ${recipientId}...`);
|
||||||
|
const encryptedMessages = await fetchMessagePlaintexts(token, recipientId);
|
||||||
|
|
||||||
|
console.log(`Found ${encryptedMessages.length} encrypted plaintexts, decrypting...`);
|
||||||
|
|
||||||
|
for (const msg of encryptedMessages) {
|
||||||
|
try {
|
||||||
|
// Use stored key if available, otherwise use password to derive it
|
||||||
|
const plaintext = await decryptMessagePlaintext(password, userId, msg.encryptedData);
|
||||||
|
plaintexts.set(msg.messageId, plaintext);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to decrypt plaintext for message ${msg.messageId}:`, error);
|
||||||
|
// Continue with other messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Decrypted ${plaintexts.size}/${encryptedMessages.length} plaintexts`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch message plaintexts from server:", error);
|
||||||
|
// Don't throw - allow history loading to continue even if plaintext fetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintexts;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { randomBytes } from "./kdf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Padding sizes that look like normal HTTP/WebSocket traffic
|
||||||
|
* These sizes are common in real web traffic to avoid fingerprinting
|
||||||
|
*/
|
||||||
|
const PADDING_BUCKETS = [64, 128, 256, 512, 1024, 2048, 4096];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds padding to a message to make it resistant to size-based fingerprinting
|
||||||
|
* Pads to the nearest bucket size to make all messages look similar
|
||||||
|
* @param data - The data to pad
|
||||||
|
* @returns Padded data with padding length prefix
|
||||||
|
*/
|
||||||
|
export function addPadding(data: string): string {
|
||||||
|
const dataBytes = new TextEncoder().encode(data);
|
||||||
|
const dataSize = dataBytes.length;
|
||||||
|
|
||||||
|
// Find the smallest bucket that fits the data
|
||||||
|
let targetSize = PADDING_BUCKETS[PADDING_BUCKETS.length - 1];
|
||||||
|
for (const bucket of PADDING_BUCKETS) {
|
||||||
|
if (bucket >= dataSize + 4) { // +4 for padding length header
|
||||||
|
targetSize = bucket;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate padding needed (subtract data size and 4-byte length header)
|
||||||
|
const paddingSize = targetSize - dataSize - 4;
|
||||||
|
const padding = randomBytes(Math.max(0, paddingSize));
|
||||||
|
|
||||||
|
// Create padded message: [4-byte length][data][random padding]
|
||||||
|
const lengthBytes = new Uint8Array(4);
|
||||||
|
const view = new DataView(lengthBytes.buffer);
|
||||||
|
view.setUint32(0, dataSize, true); // Little-endian
|
||||||
|
|
||||||
|
const padded = new Uint8Array(4 + dataSize + padding.length);
|
||||||
|
padded.set(lengthBytes, 0);
|
||||||
|
padded.set(dataBytes, 4);
|
||||||
|
padded.set(padding, 4 + dataSize);
|
||||||
|
|
||||||
|
// Return as base64 for easy transmission
|
||||||
|
// Use chunked approach to avoid "Maximum call stack size exceeded" for large arrays
|
||||||
|
// Convert Uint8Array to base64 in chunks
|
||||||
|
const chunkSize = 8192;
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < padded.length; i += chunkSize) {
|
||||||
|
const chunk = padded.slice(i, i + chunkSize);
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes padding from a message
|
||||||
|
* @param paddedData - The padded data (base64)
|
||||||
|
* @returns Original unpadded data
|
||||||
|
*/
|
||||||
|
export function removePadding(paddedData: string): string {
|
||||||
|
try {
|
||||||
|
const padded = Uint8Array.from(atob(paddedData), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
// Read length from first 4 bytes
|
||||||
|
const view = new DataView(padded.buffer);
|
||||||
|
const dataSize = view.getUint32(0, true); // Little-endian
|
||||||
|
|
||||||
|
// Extract original data
|
||||||
|
const data = padded.slice(4, 4 + dataSize);
|
||||||
|
return new TextDecoder().decode(data);
|
||||||
|
} catch (error) {
|
||||||
|
// If padding removal fails, assume it's an old message without padding
|
||||||
|
return paddedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Functions for encrypting/decrypting Signal Protocol session data
|
||||||
|
* Uses a stable key derived from password (stored in localStorage as a hash)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
|
||||||
|
import { randomBytes } from "./kdf";
|
||||||
|
import { b64, ub64 } from "../utils";
|
||||||
|
import { deriveSessionKey, exportKey, importKey, storeSessionKey, getStoredSessionKey } from "./sessionKeyStorage";
|
||||||
|
|
||||||
|
export interface EncryptedSessionData {
|
||||||
|
salt: Uint8Array; // Random salt (kept for backward compatibility, not used for key derivation)
|
||||||
|
iv: Uint8Array; // AES-GCM IV
|
||||||
|
ciphertext: Uint8Array; // encrypted session record (string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt session record using stored session key (derived from password)
|
||||||
|
* If password is provided and key is not stored, derive and store it
|
||||||
|
* If password is not provided, use stored key (for page refresh scenarios)
|
||||||
|
*/
|
||||||
|
export async function encryptSessionWithPassword(password: string | null, userId: string, sessionRecord: string): Promise<EncryptedSessionData> {
|
||||||
|
// Derive or get the stored session key
|
||||||
|
let sessionKey: CryptoKey;
|
||||||
|
const storedKeyString = getStoredSessionKey(userId);
|
||||||
|
|
||||||
|
if (storedKeyString) {
|
||||||
|
// Use stored key (works even without password on page refresh)
|
||||||
|
sessionKey = await importKey(storedKeyString);
|
||||||
|
} else if (password) {
|
||||||
|
// Derive new key and store it (as a "hash" - it's actually the derived key)
|
||||||
|
sessionKey = await deriveSessionKey(password, userId);
|
||||||
|
const keyString = await exportKey(sessionKey);
|
||||||
|
storeSessionKey(userId, keyString);
|
||||||
|
} else {
|
||||||
|
throw new Error("Cannot encrypt session: no stored key and no password provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random salt for backward compatibility (not used for key derivation)
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const sessionBytes = new TextEncoder().encode(sessionRecord);
|
||||||
|
|
||||||
|
// Encrypt using the stable key (aesGcmEncrypt generates its own IV)
|
||||||
|
const { iv, ciphertext } = await aesGcmEncrypt(sessionKey, sessionBytes);
|
||||||
|
return { salt, iv, ciphertext };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt session record using stored session key
|
||||||
|
* If password is provided and key is not stored, derive and store it
|
||||||
|
* If password is not provided, use stored key (for page refresh scenarios)
|
||||||
|
*/
|
||||||
|
export async function decryptSessionWithPassword(password: string | null, userId: string, blob: EncryptedSessionData): Promise<string> {
|
||||||
|
// Get or derive the session key
|
||||||
|
let sessionKey: CryptoKey;
|
||||||
|
const storedKeyString = getStoredSessionKey(userId);
|
||||||
|
|
||||||
|
if (storedKeyString) {
|
||||||
|
// Use stored key (works even without password on page refresh)
|
||||||
|
sessionKey = await importKey(storedKeyString);
|
||||||
|
} else if (password) {
|
||||||
|
// Derive key from password and store it
|
||||||
|
sessionKey = await deriveSessionKey(password, userId);
|
||||||
|
const keyString = await exportKey(sessionKey);
|
||||||
|
storeSessionKey(userId, keyString);
|
||||||
|
} else {
|
||||||
|
throw new Error("Cannot decrypt session: no stored key and no password provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintext = await aesGcmDecrypt(sessionKey, blob.iv, blob.ciphertext);
|
||||||
|
return new TextDecoder().decode(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode encrypted session data to JSON string for storage
|
||||||
|
*/
|
||||||
|
export function encodeSessionBlob(blob: EncryptedSessionData): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
salt: b64(blob.salt),
|
||||||
|
iv: b64(blob.iv),
|
||||||
|
ciphertext: b64(blob.ciphertext)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode encrypted session data from JSON string
|
||||||
|
*/
|
||||||
|
export function decodeSessionBlob(json: string): EncryptedSessionData {
|
||||||
|
const obj = JSON.parse(json);
|
||||||
|
return {
|
||||||
|
salt: ub64(obj.salt),
|
||||||
|
iv: ub64(obj.iv),
|
||||||
|
ciphertext: ub64(obj.ciphertext)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Functions for deriving and storing a stable key from password
|
||||||
|
* This key is used to encrypt/decrypt sessions on the server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { importPassword, deriveKEK } from "./kdf";
|
||||||
|
import { b64, ub64 } from "../utils";
|
||||||
|
|
||||||
|
const SESSION_KEY_SALT_PREFIX = "fromchat.session-key:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a stable key from password using user ID as salt
|
||||||
|
* User ID never changes, so this key will always be the same for a given password
|
||||||
|
* This key can be stored in localStorage and used to encrypt/decrypt sessions
|
||||||
|
*/
|
||||||
|
export async function deriveSessionKey(password: string, userId: string): Promise<CryptoKey> {
|
||||||
|
const salt = new TextEncoder().encode(`${SESSION_KEY_SALT_PREFIX}${userId}`);
|
||||||
|
const pw = await importPassword(password);
|
||||||
|
// Make the key extractable so we can store it in localStorage
|
||||||
|
return await deriveKEK(pw, salt, 210_000, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a CryptoKey to a base64 string for storage
|
||||||
|
*/
|
||||||
|
export async function exportKey(key: CryptoKey): Promise<string> {
|
||||||
|
const exported = await crypto.subtle.exportKey("raw", key);
|
||||||
|
return b64(new Uint8Array(exported));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a base64 string back to a CryptoKey
|
||||||
|
*/
|
||||||
|
export async function importKey(keyString: string): Promise<CryptoKey> {
|
||||||
|
const keyBytes = ub64(keyString);
|
||||||
|
// Ensure we have a proper ArrayBuffer (not SharedArrayBuffer)
|
||||||
|
// Create a new ArrayBuffer copy to avoid SharedArrayBuffer issues
|
||||||
|
const keyArray = new Uint8Array(keyBytes);
|
||||||
|
const keyBuffer = keyArray.buffer;
|
||||||
|
return await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
keyBuffer,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the session key in localStorage
|
||||||
|
*/
|
||||||
|
export function storeSessionKey(userId: string, keyString: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`sessionKey:${userId}`, keyString);
|
||||||
|
console.log(`[SessionKeyStorage] ✅ Stored session key for user ${userId} (length: ${keyString.length})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SessionKeyStorage] ❌ Failed to store session key:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the session key from localStorage
|
||||||
|
*/
|
||||||
|
export function getStoredSessionKey(userId: string): string | null {
|
||||||
|
try {
|
||||||
|
const key = localStorage.getItem(`sessionKey:${userId}`);
|
||||||
|
if (key) {
|
||||||
|
console.log(`[SessionKeyStorage] ✅ Retrieved stored session key for user ${userId} (length: ${key.length})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[SessionKeyStorage] ⚠️ No stored session key found for user ${userId}`);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SessionKeyStorage] ❌ Failed to get session key:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the session key from localStorage
|
||||||
|
*/
|
||||||
|
export function clearSessionKey(userId: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(`sessionKey:${userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear session key:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Global state to track session restoration progress
|
||||||
|
* Used to ensure messages aren't loaded before sessions are restored
|
||||||
|
*/
|
||||||
|
|
||||||
|
let isRestoring = false;
|
||||||
|
let restorePromise: Promise<void> | null = null;
|
||||||
|
let restoreComplete = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark that session restoration has started
|
||||||
|
*/
|
||||||
|
export function setRestoringSessions(promise: Promise<void>): void {
|
||||||
|
isRestoring = true;
|
||||||
|
restoreComplete = false;
|
||||||
|
restorePromise = promise;
|
||||||
|
promise.finally(() => {
|
||||||
|
isRestoring = false;
|
||||||
|
restoreComplete = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for session restoration to complete (if in progress)
|
||||||
|
*/
|
||||||
|
export async function waitForSessionRestore(): Promise<void> {
|
||||||
|
if (!isRestoring && restoreComplete) {
|
||||||
|
console.log("[SessionRestoreState] Session restoration already completed");
|
||||||
|
return; // Already completed
|
||||||
|
}
|
||||||
|
if (isRestoring && restorePromise) {
|
||||||
|
console.log("[SessionRestoreState] Waiting for session restoration to complete...");
|
||||||
|
await restorePromise;
|
||||||
|
console.log("[SessionRestoreState] Session restoration completed");
|
||||||
|
} else if (!restoreComplete) {
|
||||||
|
// No restoration in progress and not completed - mark as complete to avoid blocking
|
||||||
|
console.log("[SessionRestoreState] No session restoration in progress, marking as complete");
|
||||||
|
restoreComplete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session restoration is in progress
|
||||||
|
*/
|
||||||
|
export function isSessionRestoreInProgress(): boolean {
|
||||||
|
return isRestoring;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session restoration has completed
|
||||||
|
*/
|
||||||
|
export function hasSessionRestoreCompleted(): boolean {
|
||||||
|
return restoreComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the restore state (e.g., on logout)
|
||||||
|
*/
|
||||||
|
export function resetSessionRestoreState(): void {
|
||||||
|
isRestoring = false;
|
||||||
|
restorePromise = null;
|
||||||
|
restoreComplete = false;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* Service for syncing Signal Protocol sessions with the server
|
||||||
|
* Sessions are encrypted with password-derived key and stored on server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SignalProtocolStorage, setSessionSyncCallback, setRestoring } from "./signalStorage";
|
||||||
|
import { encryptSessionWithPassword, decryptSessionWithPassword, encodeSessionBlob, decodeSessionBlob } from "./sessionEncryption";
|
||||||
|
import { uploadSessions, fetchSessions, type SessionData } from "@/core/api/crypto/sessions";
|
||||||
|
|
||||||
|
// Global state for session sync
|
||||||
|
let syncPassword: string | null = null;
|
||||||
|
let syncToken: string | null = null;
|
||||||
|
let syncUserId: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize session sync - sets up automatic upload of sessions when they're created
|
||||||
|
* Called after login when password is available
|
||||||
|
*/
|
||||||
|
export function initializeSessionSync(userId: string, password: string, token: string): void {
|
||||||
|
console.log("Initializing session sync for user", userId);
|
||||||
|
syncUserId = userId;
|
||||||
|
syncPassword = password;
|
||||||
|
syncToken = token;
|
||||||
|
|
||||||
|
// Set up callback to upload sessions when they're stored
|
||||||
|
setSessionSyncCallback(async (address: string, record: string) => {
|
||||||
|
console.log(`Session sync callback invoked for address: ${address}`);
|
||||||
|
if (!syncPassword || !syncToken || !syncUserId) {
|
||||||
|
console.warn("Session sync not initialized (missing password/token/userId)");
|
||||||
|
return; // Not initialized yet
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = address.split(".");
|
||||||
|
const recipientId = parseInt(parts[0], 10);
|
||||||
|
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
|
||||||
|
|
||||||
|
if (isNaN(recipientId)) {
|
||||||
|
console.warn(`Invalid address format: ${address}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Encrypting session for recipient ${recipientId}...`);
|
||||||
|
const encryptedBlob = await encryptSessionWithPassword(syncPassword, syncUserId, record);
|
||||||
|
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||||
|
|
||||||
|
console.log(`Uploading session for recipient ${recipientId} to server...`);
|
||||||
|
await uploadSessions([
|
||||||
|
{
|
||||||
|
recipientId,
|
||||||
|
deviceId,
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
], syncToken);
|
||||||
|
console.log(`Successfully uploaded session for recipient ${recipientId} to server`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync session to server:", error);
|
||||||
|
// Don't throw - session is already stored in IndexedDB, sync failure shouldn't break anything
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log("Session sync callback set successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear session sync - called on logout
|
||||||
|
*/
|
||||||
|
export function clearSessionSync(): void {
|
||||||
|
syncUserId = null;
|
||||||
|
syncPassword = null;
|
||||||
|
syncToken = null;
|
||||||
|
setSessionSyncCallback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore all sessions from server and populate IndexedDB
|
||||||
|
* Called after login when password is available
|
||||||
|
*/
|
||||||
|
export async function restoreSessionsFromServer(
|
||||||
|
userId: string,
|
||||||
|
password: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log("[Session Sync] Restoring sessions from server...");
|
||||||
|
console.log("[Session Sync] Making API request to fetch sessions...");
|
||||||
|
|
||||||
|
// Fetch encrypted sessions from server
|
||||||
|
const encryptedSessions = await fetchSessions(token);
|
||||||
|
|
||||||
|
console.log(`[Session Sync] API response received: ${encryptedSessions.length} sessions found`);
|
||||||
|
|
||||||
|
if (encryptedSessions.length === 0) {
|
||||||
|
console.log("[Session Sync] No sessions to restore from server");
|
||||||
|
return; // No sessions to restore
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Session Sync] Found ${encryptedSessions.length} sessions on server, restoring...`);
|
||||||
|
|
||||||
|
const storage = new SignalProtocolStorage(userId);
|
||||||
|
|
||||||
|
// Set restoring flag to prevent sync callback from re-uploading restored sessions
|
||||||
|
setRestoring(true);
|
||||||
|
|
||||||
|
let restoredCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt and restore each session
|
||||||
|
for (const sessionData of encryptedSessions) {
|
||||||
|
try {
|
||||||
|
const address = `${sessionData.recipientId}.${sessionData.deviceId}`;
|
||||||
|
|
||||||
|
// Always restore from server to ensure we have a valid session
|
||||||
|
// Local sessions might be corrupted, so we restore from server on every reload
|
||||||
|
// The server has the authoritative copy encrypted with password-derived key
|
||||||
|
try {
|
||||||
|
const existingSession = await storage.loadSession(address);
|
||||||
|
if (existingSession) {
|
||||||
|
console.log(`[Session Sync] Local session exists for recipient ${sessionData.recipientId}, but restoring from server to ensure validity`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[Session Sync] Local session for recipient ${sessionData.recipientId} failed to load, restoring from server`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always restore from server (don't skip)
|
||||||
|
const encryptedBlob = decodeSessionBlob(sessionData.encryptedData);
|
||||||
|
// Use stored key if available, otherwise use password to derive it
|
||||||
|
const sessionRecord = await decryptSessionWithPassword(password, userId, encryptedBlob);
|
||||||
|
|
||||||
|
// Store in IndexedDB (sync callback won't fire because isRestoring is true)
|
||||||
|
await storage.storeSession(address, sessionRecord);
|
||||||
|
restoredCount++;
|
||||||
|
console.log(`[Session Sync ✅] Restored session for recipient ${sessionData.recipientId} from server`);
|
||||||
|
} catch (error) {
|
||||||
|
failedCount++;
|
||||||
|
console.warn(`Failed to restore session for recipient ${sessionData.recipientId}:`, error);
|
||||||
|
// Continue with other sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Always clear the restoring flag
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Session Sync] Restored ${restoredCount}/${encryptedSessions.length} sessions from server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Session Sync] Failed to restore sessions from server:", error);
|
||||||
|
console.error("[Session Sync] Error details:", {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
// Don't throw - allow login to continue even if session restore fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload all sessions to server
|
||||||
|
* Called after login/registration to backup all current sessions
|
||||||
|
*/
|
||||||
|
export async function uploadAllSessionsToServer(
|
||||||
|
userId: string,
|
||||||
|
password: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log("Uploading sessions to server...");
|
||||||
|
|
||||||
|
const storage = new SignalProtocolStorage(userId);
|
||||||
|
|
||||||
|
// Get all sessions from IndexedDB
|
||||||
|
const sessions = await storage.getAllSessions();
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log("No sessions in IndexedDB to upload");
|
||||||
|
return; // No sessions to upload
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${sessions.length} sessions in IndexedDB, uploading...`);
|
||||||
|
|
||||||
|
// Encrypt and prepare sessions for upload
|
||||||
|
const sessionData: SessionData[] = [];
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (const { address, record } of sessions) {
|
||||||
|
try {
|
||||||
|
// Parse address to get recipientId and deviceId
|
||||||
|
const parts = address.split(".");
|
||||||
|
const recipientId = parseInt(parts[0], 10);
|
||||||
|
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
|
||||||
|
|
||||||
|
if (isNaN(recipientId)) {
|
||||||
|
console.warn(`Invalid address format: ${address}`);
|
||||||
|
failedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt session record (use stored key if available)
|
||||||
|
const encryptedBlob = await encryptSessionWithPassword(password, userId, record);
|
||||||
|
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||||
|
|
||||||
|
sessionData.push({
|
||||||
|
recipientId,
|
||||||
|
deviceId,
|
||||||
|
encryptedData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
failedCount++;
|
||||||
|
console.warn(`Failed to encrypt session ${address}:`, error);
|
||||||
|
// Continue with other sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionData.length > 0) {
|
||||||
|
await uploadSessions(sessionData, token);
|
||||||
|
console.log(`Uploaded ${sessionData.length} sessions to server${failedCount > 0 ? ` (${failedCount} failed)` : ""}`);
|
||||||
|
} else if (failedCount > 0) {
|
||||||
|
console.warn(`Failed to upload all ${sessions.length} sessions to server`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload sessions to server:", error);
|
||||||
|
// Don't throw - allow login to continue even if upload fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a session in IndexedDB and upload to server
|
||||||
|
* This should be called instead of direct storage.storeSession when password is available
|
||||||
|
*/
|
||||||
|
export async function storeSessionWithSync(
|
||||||
|
userId: string,
|
||||||
|
address: string,
|
||||||
|
record: string,
|
||||||
|
password: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
const storage = new SignalProtocolStorage(userId);
|
||||||
|
|
||||||
|
// Store in IndexedDB first (for immediate use)
|
||||||
|
await storage.storeSession(address, record);
|
||||||
|
|
||||||
|
// Parse address to get recipientId and deviceId
|
||||||
|
const parts = address.split(".");
|
||||||
|
const recipientId = parseInt(parts[0], 10);
|
||||||
|
const deviceId = parts.length > 1 ? parseInt(parts[1], 10) : 1;
|
||||||
|
|
||||||
|
if (isNaN(recipientId)) {
|
||||||
|
console.warn(`Invalid address format: ${address}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt and upload to server (use stored key if available)
|
||||||
|
try {
|
||||||
|
const encryptedBlob = await encryptSessionWithPassword(password, userId, record);
|
||||||
|
const encryptedData = encodeSessionBlob(encryptedBlob);
|
||||||
|
|
||||||
|
await uploadSessions([
|
||||||
|
{
|
||||||
|
recipientId,
|
||||||
|
deviceId,
|
||||||
|
encryptedData
|
||||||
|
}
|
||||||
|
], token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to upload session to server:", error);
|
||||||
|
// Don't throw - session is still stored in IndexedDB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a session from IndexedDB
|
||||||
|
* Note: We don't remove from server immediately because we need password for encryption.
|
||||||
|
* Stale sessions on server will be overwritten on next login when we upload all current sessions.
|
||||||
|
*/
|
||||||
|
export async function removeSessionLocal(
|
||||||
|
userId: string,
|
||||||
|
address: string
|
||||||
|
): Promise<void> {
|
||||||
|
const storage = new SignalProtocolStorage(userId);
|
||||||
|
await storage.removeSession(address);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
/**
|
||||||
|
* Signal Protocol service wrapper
|
||||||
|
* Provides high-level API for encrypting/decrypting messages using Signal Protocol
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
SessionBuilder,
|
||||||
|
SessionCipher,
|
||||||
|
KeyHelper,
|
||||||
|
SignalProtocolAddress,
|
||||||
|
type DeviceType,
|
||||||
|
type KeyPairType
|
||||||
|
} from "@privacyresearch/libsignal-protocol-typescript";
|
||||||
|
import { SignalProtocolStorage } from "./signalStorage";
|
||||||
|
import { b64, ub64 } from "../utils";
|
||||||
|
import api from "@/core/api";
|
||||||
|
|
||||||
|
// Helper to ensure we get a proper ArrayBuffer (not SharedArrayBuffer)
|
||||||
|
function toArrayBuffer(buffer: ArrayBuffer | SharedArrayBuffer): ArrayBuffer {
|
||||||
|
if (buffer instanceof ArrayBuffer) return buffer;
|
||||||
|
// Convert SharedArrayBuffer to ArrayBuffer by copying
|
||||||
|
const view = new Uint8Array(buffer);
|
||||||
|
const copy = new Uint8Array(view.length);
|
||||||
|
copy.set(view);
|
||||||
|
return copy.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreKeyBundleData {
|
||||||
|
registrationId: number;
|
||||||
|
identityKey: string; // base64
|
||||||
|
signedPreKey: {
|
||||||
|
keyId: number;
|
||||||
|
publicKey: string; // base64
|
||||||
|
signature: string; // base64
|
||||||
|
};
|
||||||
|
preKey?: {
|
||||||
|
keyId: number;
|
||||||
|
publicKey: string; // base64
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignalProtocolService {
|
||||||
|
private storage: SignalProtocolStorage;
|
||||||
|
|
||||||
|
// Prekey configuration constants
|
||||||
|
private static readonly PREKEY_COUNT = 20;
|
||||||
|
private static readonly PREKEY_REGEN_THRESHOLD = 5; // Regenerate when fewer than this many prekeys are left
|
||||||
|
private static readonly PREKEY_REGEN_COUNT = 10; // Number of prekeys to regenerate
|
||||||
|
private static readonly SIGNED_PREKEY_ID = 1;
|
||||||
|
private static readonly BATCH_SIZE = 10;
|
||||||
|
|
||||||
|
constructor(userId: string) {
|
||||||
|
this.storage = new SignalProtocolStorage(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Signal Protocol for this user
|
||||||
|
* Generates identity keys, registration ID, and prekeys if they don't exist
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// Check if already initialized
|
||||||
|
const existingIdentity = await this.storage.getIdentityKeyPair();
|
||||||
|
if (existingIdentity) {
|
||||||
|
return; // Already initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate identity key pair
|
||||||
|
const identityKeyPair = await KeyHelper.generateIdentityKeyPair();
|
||||||
|
await this.storage.saveIdentityKeyPair(identityKeyPair);
|
||||||
|
|
||||||
|
// Generate registration ID
|
||||||
|
const registrationId = KeyHelper.generateRegistrationId();
|
||||||
|
await this.storage.saveLocalRegistrationId(registrationId);
|
||||||
|
|
||||||
|
// Generate signed prekey
|
||||||
|
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID);
|
||||||
|
// Store both the key pair and its signature
|
||||||
|
await this.storage.storeSignedPreKey(
|
||||||
|
SignalProtocolService.SIGNED_PREKEY_ID,
|
||||||
|
signedPreKey.keyPair,
|
||||||
|
new Uint8Array(signedPreKey.signature)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate prekeys (one-time keys for establishing new sessions)
|
||||||
|
// Each new conversation consumes one prekey when the first message is sent
|
||||||
|
// Generation is non-blocking (yields to event loop), so this doesn't freeze the UI
|
||||||
|
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
|
||||||
|
const preKey = await KeyHelper.generatePreKey(i);
|
||||||
|
await this.storage.storePreKey(i, preKey.keyPair);
|
||||||
|
|
||||||
|
// Yield to event loop every batchSize keys to prevent UI freezing
|
||||||
|
if (i % SignalProtocolService.BATCH_SIZE === 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure signed prekey exists and is valid, regenerating if necessary
|
||||||
|
*/
|
||||||
|
private async ensureSignedPreKey(identityKeyPair: KeyPairType): Promise<{ keyPair: KeyPairType; signature: Uint8Array }> {
|
||||||
|
let signature = await this.storage.loadSignedPreKeySignature(SignalProtocolService.SIGNED_PREKEY_ID);
|
||||||
|
let signedPreKey = await this.storage.loadSignedPreKey(SignalProtocolService.SIGNED_PREKEY_ID);
|
||||||
|
|
||||||
|
if (!signedPreKey || !signature) {
|
||||||
|
// Signed prekey or signature missing - regenerate both to ensure consistency
|
||||||
|
const signedPreKeyWithSig = await KeyHelper.generateSignedPreKey(identityKeyPair, SignalProtocolService.SIGNED_PREKEY_ID);
|
||||||
|
await this.storage.storeSignedPreKey(
|
||||||
|
SignalProtocolService.SIGNED_PREKEY_ID,
|
||||||
|
signedPreKeyWithSig.keyPair,
|
||||||
|
new Uint8Array(signedPreKeyWithSig.signature)
|
||||||
|
);
|
||||||
|
signedPreKey = signedPreKeyWithSig.keyPair;
|
||||||
|
signature = new Uint8Array(signedPreKeyWithSig.signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { keyPair: signedPreKey, signature };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an available prekey, regenerating if necessary
|
||||||
|
*/
|
||||||
|
private async findOrRegeneratePreKey(): Promise<{ keyPair: KeyPairType; keyId: number }> {
|
||||||
|
// Find the first available prekey
|
||||||
|
let preKey: KeyPairType | undefined;
|
||||||
|
let preKeyId = 0;
|
||||||
|
let availableCount = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
|
||||||
|
const candidate = await this.storage.loadPreKey(i);
|
||||||
|
if (candidate) {
|
||||||
|
availableCount++;
|
||||||
|
if (!preKey) {
|
||||||
|
preKey = candidate;
|
||||||
|
preKeyId = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're running low on prekeys, regenerate more proactively
|
||||||
|
if (availableCount < SignalProtocolService.PREKEY_REGEN_THRESHOLD) {
|
||||||
|
console.warn(`Low on prekeys (${availableCount} remaining), regenerating...`);
|
||||||
|
|
||||||
|
// Find the next available ID to regenerate from
|
||||||
|
let nextId = SignalProtocolService.PREKEY_COUNT + 1;
|
||||||
|
for (let i = 1; i <= SignalProtocolService.PREKEY_COUNT; i++) {
|
||||||
|
const existing = await this.storage.loadPreKey(i);
|
||||||
|
if (!existing) {
|
||||||
|
nextId = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate prekeys starting from nextId
|
||||||
|
for (let i = 0; i < SignalProtocolService.PREKEY_REGEN_COUNT; i++) {
|
||||||
|
const keyId = nextId + i;
|
||||||
|
const existing = await this.storage.loadPreKey(keyId);
|
||||||
|
if (!existing) {
|
||||||
|
const newPreKey = await KeyHelper.generatePreKey(keyId);
|
||||||
|
await this.storage.storePreKey(keyId, newPreKey.keyPair);
|
||||||
|
if (!preKey) {
|
||||||
|
preKey = newPreKey.keyPair;
|
||||||
|
preKeyId = keyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emergency fallback if still no prekey
|
||||||
|
if (!preKey) {
|
||||||
|
console.error("No prekeys available, emergency regeneration...");
|
||||||
|
const newPreKey = await KeyHelper.generatePreKey(1);
|
||||||
|
await this.storage.storePreKey(1, newPreKey.keyPair);
|
||||||
|
preKey = newPreKey.keyPair;
|
||||||
|
preKeyId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { keyPair: preKey, keyId: preKeyId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prekey bundle for this user to share with others
|
||||||
|
*/
|
||||||
|
async getPreKeyBundle(): Promise<PreKeyBundleData> {
|
||||||
|
const identityKeyPair = await this.storage.getIdentityKeyPair();
|
||||||
|
if (!identityKeyPair) {
|
||||||
|
throw new Error("Signal Protocol not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationId = await this.storage.getLocalRegistrationId();
|
||||||
|
if (!registrationId) {
|
||||||
|
throw new Error("Registration ID not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair);
|
||||||
|
const { keyPair: preKey, keyId: preKeyId } = await this.findOrRegeneratePreKey();
|
||||||
|
|
||||||
|
return {
|
||||||
|
registrationId: registrationId,
|
||||||
|
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
|
||||||
|
signedPreKey: {
|
||||||
|
keyId: SignalProtocolService.SIGNED_PREKEY_ID,
|
||||||
|
publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
|
||||||
|
signature: b64(signature)
|
||||||
|
},
|
||||||
|
preKey: {
|
||||||
|
keyId: preKeyId,
|
||||||
|
publicKey: b64(new Uint8Array(preKey.pubKey))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available prekeys for uploading to the server
|
||||||
|
*/
|
||||||
|
async getAllPreKeys(): Promise<Array<{ keyId: number; publicKey: string }>> {
|
||||||
|
const prekeys: Array<{ keyId: number; publicKey: string }> = [];
|
||||||
|
|
||||||
|
// Check all possible prekey IDs (including regenerated ones beyond initial count)
|
||||||
|
// We check up to PREKEY_COUNT + PREKEY_REGEN_COUNT to include regenerated prekeys
|
||||||
|
const maxPreKeyId = SignalProtocolService.PREKEY_COUNT + SignalProtocolService.PREKEY_REGEN_COUNT;
|
||||||
|
|
||||||
|
for (let i = 1; i <= maxPreKeyId; i++) {
|
||||||
|
const prekey = await this.storage.loadPreKey(i);
|
||||||
|
if (prekey) {
|
||||||
|
prekeys.push({
|
||||||
|
keyId: i,
|
||||||
|
publicKey: b64(new Uint8Array(prekey.pubKey))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prekeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base bundle (without prekey) for uploading all prekeys
|
||||||
|
*/
|
||||||
|
async getBaseBundle(): Promise<Omit<PreKeyBundleData, "preKey">> {
|
||||||
|
const identityKeyPair = await this.storage.getIdentityKeyPair();
|
||||||
|
if (!identityKeyPair) {
|
||||||
|
throw new Error("Signal Protocol not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrationId = await this.storage.getLocalRegistrationId();
|
||||||
|
if (!registrationId) {
|
||||||
|
throw new Error("Registration ID not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { keyPair: signedPreKey, signature } = await this.ensureSignedPreKey(identityKeyPair);
|
||||||
|
|
||||||
|
return {
|
||||||
|
registrationId: registrationId,
|
||||||
|
identityKey: b64(new Uint8Array(identityKeyPair.pubKey)),
|
||||||
|
signedPreKey: {
|
||||||
|
keyId: SignalProtocolService.SIGNED_PREKEY_ID,
|
||||||
|
publicKey: b64(new Uint8Array(signedPreKey.pubKey)),
|
||||||
|
signature: b64(signature)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a prekey bundle from another user and establish a session
|
||||||
|
*/
|
||||||
|
async processPreKeyBundle(recipientId: number, bundle: PreKeyBundleData): Promise<void> {
|
||||||
|
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||||
|
|
||||||
|
const identityKeyBuf = ub64(bundle.identityKey);
|
||||||
|
const signedPreKeyPubBuf = ub64(bundle.signedPreKey.publicKey);
|
||||||
|
const signedPreKeySigBuf = ub64(bundle.signedPreKey.signature);
|
||||||
|
|
||||||
|
const deviceBundle: DeviceType = {
|
||||||
|
identityKey: toArrayBuffer(identityKeyBuf.buffer.slice(identityKeyBuf.byteOffset, identityKeyBuf.byteOffset + identityKeyBuf.byteLength)),
|
||||||
|
signedPreKey: {
|
||||||
|
keyId: bundle.signedPreKey.keyId,
|
||||||
|
publicKey: toArrayBuffer(signedPreKeyPubBuf.buffer.slice(signedPreKeyPubBuf.byteOffset, signedPreKeyPubBuf.byteOffset + signedPreKeyPubBuf.byteLength)),
|
||||||
|
signature: toArrayBuffer(signedPreKeySigBuf.buffer.slice(signedPreKeySigBuf.byteOffset, signedPreKeySigBuf.byteOffset + signedPreKeySigBuf.byteLength))
|
||||||
|
},
|
||||||
|
preKey: bundle.preKey ? {
|
||||||
|
keyId: bundle.preKey.keyId,
|
||||||
|
publicKey: (() => {
|
||||||
|
const preKeyBuf = ub64(bundle.preKey!.publicKey);
|
||||||
|
return toArrayBuffer(preKeyBuf.buffer.slice(preKeyBuf.byteOffset, preKeyBuf.byteOffset + preKeyBuf.byteLength));
|
||||||
|
})()
|
||||||
|
} : undefined,
|
||||||
|
registrationId: bundle.registrationId
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionBuilder = new SessionBuilder(this.storage, address);
|
||||||
|
await sessionBuilder.processPreKey(deviceBundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a message for a recipient
|
||||||
|
*/
|
||||||
|
async encryptMessage(recipientId: number, plaintext: string): Promise<{ type: number; body: string }> {
|
||||||
|
try {
|
||||||
|
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||||
|
|
||||||
|
const sessionCipher = new SessionCipher(this.storage, address);
|
||||||
|
const plaintextBuffer = toArrayBuffer(new TextEncoder().encode(plaintext).buffer);
|
||||||
|
const encryptResult = await sessionCipher.encrypt(plaintextBuffer);
|
||||||
|
const { type, body } = encryptResult;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("Encryption failed: no body in ciphertext");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The library returns body as ArrayBuffer or Uint8Array, we need to convert it to base64 string
|
||||||
|
// Always convert to Uint8Array first, then to base64, regardless of input type
|
||||||
|
let bodyArray: Uint8Array;
|
||||||
|
const bodyAny = body as any;
|
||||||
|
|
||||||
|
if (typeof body === "string") {
|
||||||
|
// String input - check if it's already base64
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (base64Regex.test(body)) {
|
||||||
|
// Already base64, use as-is
|
||||||
|
bodyArray = ub64(body);
|
||||||
|
} else {
|
||||||
|
// String contains binary data, convert to Uint8Array
|
||||||
|
bodyArray = new Uint8Array([...body].map(c => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
} else if (bodyAny instanceof Uint8Array) {
|
||||||
|
bodyArray = bodyAny;
|
||||||
|
} else if (bodyAny instanceof ArrayBuffer) {
|
||||||
|
bodyArray = new Uint8Array(bodyAny);
|
||||||
|
} else {
|
||||||
|
// Try to convert unknown type
|
||||||
|
if (bodyAny.buffer && bodyAny.buffer instanceof ArrayBuffer) {
|
||||||
|
bodyArray = new Uint8Array(bodyAny.buffer, bodyAny.byteOffset || 0, bodyAny.byteLength || bodyAny.buffer.byteLength);
|
||||||
|
} else {
|
||||||
|
bodyArray = new Uint8Array(bodyAny as ArrayBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64
|
||||||
|
const bodyBase64 = b64(bodyArray);
|
||||||
|
|
||||||
|
// Final validation - ensure the result is valid base64
|
||||||
|
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||||
|
if (!base64Regex.test(bodyBase64)) {
|
||||||
|
throw new Error(`Failed to convert body to base64: result contains invalid characters. Length: ${bodyBase64.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that it can be decoded
|
||||||
|
try {
|
||||||
|
atob(bodyBase64.substring(0, Math.min(4, bodyBase64.length)));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to convert body to base64: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type, body: bodyBase64 };
|
||||||
|
} catch (error) {
|
||||||
|
// Log detailed error information for debugging
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Signal Protocol encryption failed:", {
|
||||||
|
recipientId,
|
||||||
|
plaintextLength: plaintext.length,
|
||||||
|
error: errorMessage
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to encrypt message: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a message from a sender
|
||||||
|
*/
|
||||||
|
async decryptMessage(senderId: number, ciphertext: { type: number; body: string }): Promise<string> {
|
||||||
|
if (!ciphertext.body || typeof ciphertext.body !== "string") {
|
||||||
|
throw new Error("Invalid ciphertext: body is missing or not a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = new SignalProtocolAddress(senderId.toString(), 1);
|
||||||
|
|
||||||
|
const sessionCipher = new SessionCipher(this.storage, address);
|
||||||
|
|
||||||
|
// Handle both PreKeyWhisperMessage (type 3) and WhisperMessage (type 1)
|
||||||
|
// ciphertext.body is a base64 string from the Signal Protocol library
|
||||||
|
let bodyBuffer: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
const { buffer, byteOffset, byteLength } = ub64(ciphertext.body);
|
||||||
|
bodyBuffer = toArrayBuffer(buffer.slice(byteOffset, byteOffset + byteLength));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to decode ciphertext body: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let plaintextBytes: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
if (ciphertext.type === 3) {
|
||||||
|
// PreKeyWhisperMessage - this will consume a prekey
|
||||||
|
// Count available prekeys before decryption
|
||||||
|
const prekeysBefore = await this.countAvailablePrekeys();
|
||||||
|
|
||||||
|
plaintextBytes = await sessionCipher.decryptPreKeyWhisperMessage(bodyBuffer);
|
||||||
|
|
||||||
|
// Check if a prekey was consumed (removed by the library)
|
||||||
|
const prekeysAfter = await this.countAvailablePrekeys();
|
||||||
|
if (prekeysBefore > prekeysAfter) {
|
||||||
|
// A prekey was consumed - refresh the bundle in the background
|
||||||
|
// This ensures new users can still message you while you're offline
|
||||||
|
this.refreshPreKeyBundle().catch(err =>
|
||||||
|
console.warn("Failed to refresh prekey bundle after consumption:", err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// WhisperMessage - uses existing session, no prekey consumed
|
||||||
|
plaintextBytes = await sessionCipher.decryptWhisperMessage(bodyBuffer);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log detailed error information for debugging
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
// Handle different types of decryption errors
|
||||||
|
if (errorMessage.includes("Bad MAC")) {
|
||||||
|
console.warn(`Bad MAC error detected for sender ${senderId} (type ${ciphertext.type}). Session may be out of sync.`);
|
||||||
|
|
||||||
|
// For both types, remove the session so next message can re-establish it
|
||||||
|
try {
|
||||||
|
await this.storage.removeSession(address.toString());
|
||||||
|
console.warn(`Removed corrupted session for sender ${senderId}. Sender needs to send a new message to re-establish session.`);
|
||||||
|
} catch (resetError) {
|
||||||
|
console.error("Failed to remove session:", resetError);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
errorMessage.includes("Tried to decrypt on a sending chain") ||
|
||||||
|
errorMessage.includes("No record for device") ||
|
||||||
|
errorMessage.includes("Message key not found") ||
|
||||||
|
errorMessage.includes("counter was repeated") ||
|
||||||
|
errorMessage.includes("key was not filled")
|
||||||
|
) {
|
||||||
|
// These errors indicate the session state is corrupted, missing, or out of sync
|
||||||
|
// Remove the session so it can be re-established
|
||||||
|
console.warn(`Session state error for sender ${senderId}: ${errorMessage}. Removing session.`);
|
||||||
|
try {
|
||||||
|
await this.storage.removeSession(address.toString());
|
||||||
|
console.warn(`Removed corrupted session for sender ${senderId}. Sender needs to send a new message to re-establish session.`);
|
||||||
|
} catch (resetError) {
|
||||||
|
console.error("Failed to remove session:", resetError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Signal Protocol decryption failed:", {
|
||||||
|
senderId,
|
||||||
|
type: ciphertext.type,
|
||||||
|
bodyLength: ciphertext.body.length,
|
||||||
|
bodyFirst50: ciphertext.body.substring(0, 50),
|
||||||
|
bodyLast50: ciphertext.body.substring(Math.max(0, ciphertext.body.length - 50)),
|
||||||
|
bodyIsBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(ciphertext.body),
|
||||||
|
error: errorMessage
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to decrypt message: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextDecoder().decode(plaintextBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count available prekeys
|
||||||
|
*/
|
||||||
|
private async countAvailablePrekeys(): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
const maxPreKeyId = SignalProtocolService.PREKEY_COUNT + SignalProtocolService.PREKEY_REGEN_COUNT;
|
||||||
|
|
||||||
|
for (let i = 1; i <= maxPreKeyId; i++) {
|
||||||
|
const prekey = await this.storage.loadPreKey(i);
|
||||||
|
if (prekey) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh prekey bundle after a prekey was consumed
|
||||||
|
* This ensures new users can still message you while you're offline
|
||||||
|
* Uploads all available prekeys to the server for rotation
|
||||||
|
*/
|
||||||
|
private async refreshPreKeyBundle(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const token = api.user.auth.getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
console.warn("No auth token, cannot refresh prekey bundle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseBundle = await this.getBaseBundle();
|
||||||
|
const prekeys = await this.getAllPreKeys();
|
||||||
|
|
||||||
|
// Upload all prekeys in the background
|
||||||
|
api.crypto.prekeys.uploadAllPreKeys(baseBundle, prekeys, token).catch(err =>
|
||||||
|
console.warn("Failed to upload all prekeys:", err)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh prekey bundle:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session exists for a recipient
|
||||||
|
*/
|
||||||
|
async hasSession(recipientId: number): Promise<boolean> {
|
||||||
|
const address = new SignalProtocolAddress(recipientId.toString(), 1);
|
||||||
|
const sessionCipher = new SessionCipher(this.storage, address);
|
||||||
|
return await sessionCipher.hasOpenSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Centralized Signal Protocol initialization
|
||||||
|
* Used in login, register, and token restoration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SignalProtocolService } from "./signalProtocol";
|
||||||
|
import { uploadAllPreKeys } from "@/core/api/crypto/prekeys";
|
||||||
|
import { initializeSessionSync, restoreSessionsFromServer, uploadAllSessionsToServer } from "./sessionSync";
|
||||||
|
import { initializeMessagePlaintextSync } from "./messagePlaintextSync";
|
||||||
|
import { deriveSessionKey, exportKey, storeSessionKey } from "./sessionKeyStorage";
|
||||||
|
|
||||||
|
export interface SignalProtocolInitOptions {
|
||||||
|
userId: string;
|
||||||
|
password: string;
|
||||||
|
token: string;
|
||||||
|
restoreSessions?: boolean;
|
||||||
|
uploadSessions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Signal Protocol with all necessary setup
|
||||||
|
* This function handles:
|
||||||
|
* - Signal Protocol service initialization
|
||||||
|
* - Session key derivation and storage
|
||||||
|
* - Session sync initialization
|
||||||
|
* - Message plaintext sync initialization
|
||||||
|
* - Prekey bundle upload
|
||||||
|
* - Session restoration from server (optional)
|
||||||
|
* - Plaintext restoration from server (optional)
|
||||||
|
* - Session upload to server (optional)
|
||||||
|
* - Plaintext upload to server (optional)
|
||||||
|
*/
|
||||||
|
export async function initializeSignalProtocol({
|
||||||
|
userId,
|
||||||
|
password,
|
||||||
|
token,
|
||||||
|
restoreSessions = false,
|
||||||
|
uploadSessions = false
|
||||||
|
}: SignalProtocolInitOptions): Promise<void> {
|
||||||
|
console.log("========================================");
|
||||||
|
console.log("[Signal Protocol Init] 🚀 STARTING SIGNAL PROTOCOL INITIALIZATION");
|
||||||
|
console.log("[Signal Protocol Init] User ID:", userId);
|
||||||
|
console.log("[Signal Protocol Init] Has password:", !!password);
|
||||||
|
console.log("[Signal Protocol Init] Has token:", !!token);
|
||||||
|
console.log("========================================");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Derive and store session key
|
||||||
|
console.log("[Signal Protocol Init] Step 1: Deriving session key...");
|
||||||
|
const sessionKey = await deriveSessionKey(password, userId);
|
||||||
|
const keyString = await exportKey(sessionKey);
|
||||||
|
storeSessionKey(userId, keyString);
|
||||||
|
console.log("[Signal Protocol Init] Step 1: ✅ Session key derived and stored");
|
||||||
|
|
||||||
|
// Step 2: Initialize Signal Protocol service
|
||||||
|
console.log("[Signal Protocol Init] Step 2: Initializing Signal Protocol service...");
|
||||||
|
const signalService = new SignalProtocolService(userId);
|
||||||
|
await signalService.initialize();
|
||||||
|
console.log("[Signal Protocol Init] Step 2: ✅ Signal Protocol service initialized");
|
||||||
|
|
||||||
|
// Step 3: Initialize session sync
|
||||||
|
console.log("[Signal Protocol Init] Step 3: Initializing session sync...");
|
||||||
|
initializeSessionSync(userId, password, token);
|
||||||
|
console.log("[Signal Protocol Init] Step 3: ✅ Session sync initialized");
|
||||||
|
|
||||||
|
// Step 4: Initialize message plaintext sync
|
||||||
|
console.log("[Signal Protocol Init] Step 4: Initializing message plaintext sync...");
|
||||||
|
initializeMessagePlaintextSync(userId, password, token);
|
||||||
|
console.log("[Signal Protocol Init] Step 4: ✅ Message plaintext sync initialized");
|
||||||
|
|
||||||
|
// Step 5: Restore sessions from server (if requested)
|
||||||
|
if (restoreSessions) {
|
||||||
|
console.log("========================================");
|
||||||
|
console.log("[Signal Protocol Init] Step 5: ⚠️ RESTORING SESSIONS FROM SERVER");
|
||||||
|
console.log("========================================");
|
||||||
|
const { setRestoringSessions } = await import("./sessionRestoreState");
|
||||||
|
const restorePromise = restoreSessionsFromServer(userId, password, token);
|
||||||
|
setRestoringSessions(restorePromise);
|
||||||
|
try {
|
||||||
|
await restorePromise;
|
||||||
|
console.log("========================================");
|
||||||
|
console.log("[Signal Protocol Init] Step 5: ✅ SESSIONS RESTORED FROM SERVER");
|
||||||
|
console.log("========================================");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("========================================");
|
||||||
|
console.error("[Signal Protocol Init] Step 5: ❌ SESSION RESTORATION FAILED");
|
||||||
|
console.error("[Signal Protocol Init] Error:", error);
|
||||||
|
console.error("========================================");
|
||||||
|
// Continue even if restoration fails
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mark restore as complete if we're not restoring (to avoid blocking message loading)
|
||||||
|
const { setRestoringSessions } = await import("./sessionRestoreState");
|
||||||
|
setRestoringSessions(Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Upload prekey bundle
|
||||||
|
console.log("[Signal Protocol Init] Step 6: Uploading prekey bundle...");
|
||||||
|
const bundle = await signalService.getPreKeyBundle();
|
||||||
|
const { uploadPreKeyBundle } = await import("@/core/api/crypto/prekeys");
|
||||||
|
await uploadPreKeyBundle(bundle, token);
|
||||||
|
console.log("[Signal Protocol Init] Step 6: ✅ Prekey bundle uploaded");
|
||||||
|
|
||||||
|
// Step 7: Upload all prekeys
|
||||||
|
console.log("[Signal Protocol Init] Step 7: Uploading all prekeys...");
|
||||||
|
const baseBundle = await signalService.getBaseBundle();
|
||||||
|
const prekeys = await signalService.getAllPreKeys();
|
||||||
|
await uploadAllPreKeys(baseBundle, prekeys, token);
|
||||||
|
console.log(`[Signal Protocol Init] Step 7: ✅ Uploaded ${prekeys.length} prekeys to server`);
|
||||||
|
|
||||||
|
// Step 8: Upload all sessions to server (if requested)
|
||||||
|
if (uploadSessions) {
|
||||||
|
console.log("[Signal Protocol Init] Step 8: Uploading all sessions to server...");
|
||||||
|
try {
|
||||||
|
await uploadAllSessionsToServer(userId, password, token);
|
||||||
|
console.log("[Signal Protocol Init] Step 8: ✅ Sessions uploaded to server");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Signal Protocol Init] Step 8: ❌ Failed to upload sessions:", error);
|
||||||
|
// Continue even if upload fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9: Restore message plaintexts from server (if requested)
|
||||||
|
// Note: Message plaintext restoration is handled per-conversation when needed
|
||||||
|
// No bulk restoration needed here
|
||||||
|
|
||||||
|
// Step 10: Upload all message plaintexts to server (if requested)
|
||||||
|
// Note: Message plaintext upload is handled automatically when messages are sent
|
||||||
|
// No bulk upload needed here
|
||||||
|
|
||||||
|
console.log("========================================");
|
||||||
|
console.log("[Signal Protocol Init] ✅ ALL SIGNAL PROTOCOL INITIALIZATION COMPLETED");
|
||||||
|
console.log("========================================");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("========================================");
|
||||||
|
console.error("[Signal Protocol Init] ❌ SIGNAL PROTOCOL INITIALIZATION FAILED");
|
||||||
|
console.error("[Signal Protocol Init] Error:", error);
|
||||||
|
console.error("========================================");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDB storage implementation for Signal Protocol
|
||||||
|
* Stores identity keys, prekeys, signed prekeys, and session states
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StorageType, KeyPairType, Direction } from "@privacyresearch/libsignal-protocol-typescript";
|
||||||
|
|
||||||
|
const DB_NAME = "signal_protocol_db";
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
interface SignalDB {
|
||||||
|
identityKeys: IDBObjectStore;
|
||||||
|
preKeys: IDBObjectStore;
|
||||||
|
signedPreKeys: IDBObjectStore;
|
||||||
|
sessions: IDBObjectStore;
|
||||||
|
registrationId: IDBObjectStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
// Identity keys store: key = userId, value = { publicKey, privateKey }
|
||||||
|
if (!db.objectStoreNames.contains("identityKeys")) {
|
||||||
|
db.createObjectStore("identityKeys", { keyPath: "userId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prekeys store: key = userId + preKeyId, value = { userId, preKeyId, publicKey, privateKey }
|
||||||
|
if (!db.objectStoreNames.contains("preKeys")) {
|
||||||
|
const preKeysStore = db.createObjectStore("preKeys", { keyPath: ["userId", "preKeyId"] });
|
||||||
|
preKeysStore.createIndex("userId", "userId", { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed prekeys store: key = userId, value = { userId, keyId, publicKey, privateKey, signature }
|
||||||
|
if (!db.objectStoreNames.contains("signedPreKeys")) {
|
||||||
|
db.createObjectStore("signedPreKeys", { keyPath: "userId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions store: key = userId + recipientId, value = { userId, recipientId, deviceId, record }
|
||||||
|
// Note: recipientId is stored in the deviceId field for backward compatibility
|
||||||
|
// The actual deviceId is always 1 for now
|
||||||
|
if (!db.objectStoreNames.contains("sessions")) {
|
||||||
|
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["userId", "deviceId"] });
|
||||||
|
sessionsStore.createIndex("userId", "userId", { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration ID store: key = userId, value = { userId, registrationId }
|
||||||
|
if (!db.objectStoreNames.contains("registrationId")) {
|
||||||
|
db.createObjectStore("registrationId", { keyPath: "userId" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStore(storeName: keyof SignalDB, mode: IDBTransactionMode = "readonly"): Promise<IDBObjectStore> {
|
||||||
|
const db = await openDB();
|
||||||
|
const tx = db.transaction([storeName], mode);
|
||||||
|
return tx.objectStore(storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert Uint8Array to ArrayBuffer
|
||||||
|
function toArrayBuffer(u8: Uint8Array | ArrayBuffer | ArrayBufferLike): ArrayBuffer {
|
||||||
|
if (u8 instanceof ArrayBuffer) return u8;
|
||||||
|
|
||||||
|
// Check if SharedArrayBuffer is available (requires COOP/COEP headers)
|
||||||
|
const SharedArrayBufferConstructor = typeof SharedArrayBuffer !== "undefined" ? SharedArrayBuffer : null;
|
||||||
|
|
||||||
|
if (SharedArrayBufferConstructor && u8 instanceof SharedArrayBufferConstructor) {
|
||||||
|
// Convert SharedArrayBuffer to ArrayBuffer by copying
|
||||||
|
const view = new Uint8Array(u8);
|
||||||
|
const copy = new Uint8Array(view.length);
|
||||||
|
copy.set(view);
|
||||||
|
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
|
||||||
|
return copy.buffer as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uint8Array case - buffer might be SharedArrayBuffer, so copy it
|
||||||
|
if (u8 instanceof Uint8Array) {
|
||||||
|
const buffer = u8.buffer;
|
||||||
|
if (SharedArrayBufferConstructor && buffer instanceof SharedArrayBufferConstructor) {
|
||||||
|
const copy = new Uint8Array(u8.length);
|
||||||
|
copy.set(u8);
|
||||||
|
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
|
||||||
|
return copy.buffer as ArrayBuffer;
|
||||||
|
}
|
||||||
|
const sliced = buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||||
|
// Ensure we return ArrayBuffer, not SharedArrayBuffer
|
||||||
|
if (SharedArrayBufferConstructor && sliced instanceof SharedArrayBufferConstructor) {
|
||||||
|
const copy = new Uint8Array(sliced);
|
||||||
|
// copy.buffer is always ArrayBuffer for a newly created Uint8Array
|
||||||
|
return copy.buffer as unknown as ArrayBuffer;
|
||||||
|
}
|
||||||
|
// TypeScript doesn't know that slice() returns ArrayBuffer when buffer is ArrayBuffer
|
||||||
|
// But we've already checked it's not SharedArrayBuffer, so it must be ArrayBuffer
|
||||||
|
return sliced as unknown as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: treat as ArrayBuffer
|
||||||
|
return u8 as unknown as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert ArrayBuffer to Uint8Array
|
||||||
|
function toUint8Array(ab: ArrayBuffer | Uint8Array): Uint8Array {
|
||||||
|
if (ab instanceof Uint8Array) return ab;
|
||||||
|
return new Uint8Array(ab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global session sync callback - set by sessionSync service
|
||||||
|
let sessionSyncCallback: ((address: string, record: string) => Promise<void>) | null = null;
|
||||||
|
// Flag to prevent sync callback during restoration (to avoid re-uploading restored sessions)
|
||||||
|
let isRestoring = false;
|
||||||
|
|
||||||
|
export function setSessionSyncCallback(callback: ((address: string, record: string) => Promise<void>) | null): void {
|
||||||
|
sessionSyncCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRestoring(restoring: boolean): void {
|
||||||
|
isRestoring = restoring;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SignalProtocolStorage implements StorageType {
|
||||||
|
private userId: string;
|
||||||
|
|
||||||
|
constructor(userId: string) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity Key Management
|
||||||
|
async getIdentityKeyPair(): Promise<KeyPairType | undefined> {
|
||||||
|
const store = await getStore("identityKeys");
|
||||||
|
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get(this.userId);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
if (!data) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
pubKey: toArrayBuffer(data.publicKey),
|
||||||
|
privKey: toArrayBuffer(data.privateKey)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLocalRegistrationId(): Promise<number | undefined> {
|
||||||
|
const store = await getStore("registrationId");
|
||||||
|
const result = await new Promise<{ registrationId: number } | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get(this.userId);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
resolve(data ? { registrationId: data.registrationId } : undefined);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result?.registrationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isTrustedIdentity(identifier: string, identityKey: ArrayBuffer, direction: Direction): Promise<boolean> {
|
||||||
|
// For now, always trust (can be enhanced with key verification)
|
||||||
|
// In production, you'd check against previously stored identity keys
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveIdentity(encodedAddress: string, publicKey: ArrayBuffer, nonblockingApproval?: boolean): Promise<boolean> {
|
||||||
|
// Store other users' identity keys if needed
|
||||||
|
// For now, we trust all identities
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for initialization (not part of StorageType interface)
|
||||||
|
async saveIdentityKeyPair(keyPair: KeyPairType): Promise<void> {
|
||||||
|
const store = await getStore("identityKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
publicKey: toUint8Array(keyPair.pubKey),
|
||||||
|
privateKey: toUint8Array(keyPair.privKey)
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveLocalRegistrationId(registrationId: number): Promise<void> {
|
||||||
|
const store = await getStore("registrationId", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
registrationId: registrationId
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreKey Management
|
||||||
|
async loadPreKey(encodedAddress: string | number): Promise<KeyPairType | undefined> {
|
||||||
|
const preKeyId = typeof encodedAddress === "number" ? encodedAddress : parseInt(encodedAddress, 10);
|
||||||
|
const store = await getStore("preKeys");
|
||||||
|
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get([this.userId, preKeyId]);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
if (!data) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
pubKey: toArrayBuffer(data.publicKey),
|
||||||
|
privKey: toArrayBuffer(data.privateKey)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storePreKey(keyId: number | string, keyPair: KeyPairType): Promise<void> {
|
||||||
|
const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("preKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
preKeyId: preKeyId,
|
||||||
|
publicKey: toUint8Array(keyPair.pubKey),
|
||||||
|
privateKey: toUint8Array(keyPair.privKey)
|
||||||
|
});
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePreKey(keyId: number | string): Promise<void> {
|
||||||
|
const preKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("preKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.delete([this.userId, preKeyId]);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed PreKey Management
|
||||||
|
async loadSignedPreKey(keyId: number | string): Promise<KeyPairType | undefined> {
|
||||||
|
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("signedPreKeys");
|
||||||
|
const result = await new Promise<KeyPairType | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get(this.userId);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
if (!data || data.keyId !== signedPreKeyId) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
pubKey: toArrayBuffer(data.publicKey),
|
||||||
|
privKey: toArrayBuffer(data.privateKey)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeSignedPreKey(keyId: number | string, keyPair: KeyPairType, signature?: Uint8Array): Promise<void> {
|
||||||
|
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("signedPreKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
interface SignedPreKeyData {
|
||||||
|
userId: string;
|
||||||
|
keyId: number;
|
||||||
|
publicKey: Uint8Array;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
signature?: Uint8Array;
|
||||||
|
}
|
||||||
|
const data: SignedPreKeyData = {
|
||||||
|
userId: this.userId,
|
||||||
|
keyId: signedPreKeyId,
|
||||||
|
publicKey: toUint8Array(keyPair.pubKey),
|
||||||
|
privateKey: toUint8Array(keyPair.privKey)
|
||||||
|
};
|
||||||
|
if (signature) {
|
||||||
|
data.signature = toUint8Array(signature);
|
||||||
|
}
|
||||||
|
const request = store.put(data);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSignedPreKeySignature(keyId: number | string): Promise<Uint8Array | undefined> {
|
||||||
|
const signedPreKeyId = typeof keyId === "number" ? keyId : parseInt(keyId, 10);
|
||||||
|
const store = await getStore("signedPreKeys");
|
||||||
|
const result = await new Promise<{ signature?: Uint8Array } | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get(this.userId);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
if (!data || data.keyId !== signedPreKeyId) {
|
||||||
|
resolve(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(data.signature ? { signature: toUint8Array(data.signature) } : undefined);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
return result?.signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSignedPreKey(keyId: number | string): Promise<void> {
|
||||||
|
const store = await getStore("signedPreKeys", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.delete(this.userId);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session Management
|
||||||
|
async loadSession(encodedAddress: string): Promise<string | undefined> {
|
||||||
|
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
|
||||||
|
// recipientId is the other user's ID, deviceId is always 1 for now
|
||||||
|
const parts = encodedAddress.split(".");
|
||||||
|
const recipientId = parts[0]; // First part is the recipient's user ID
|
||||||
|
|
||||||
|
// Load using recipientId as the key (stored in deviceId field for backward compatibility)
|
||||||
|
// Ensure we search with string to match how we stored it
|
||||||
|
const store = await getStore("sessions");
|
||||||
|
const result = await new Promise<string | undefined>((resolve, reject) => {
|
||||||
|
const request = store.get([this.userId, String(recipientId)]);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const data = request.result;
|
||||||
|
if (data && data.record && typeof data.record === "string" && data.record.length > 0) {
|
||||||
|
console.log(`[SignalStorage] ✅ Loaded session for recipient ${recipientId} (address: ${encodedAddress})`);
|
||||||
|
resolve(data.record);
|
||||||
|
} else {
|
||||||
|
// Try with number if string didn't work (backward compatibility)
|
||||||
|
if (!data && !isNaN(Number(recipientId))) {
|
||||||
|
const numRequest = store.get([this.userId, Number(recipientId)]);
|
||||||
|
numRequest.onsuccess = () => {
|
||||||
|
const numData = numRequest.result;
|
||||||
|
if (numData && numData.record && typeof numData.record === "string" && numData.record.length > 0) {
|
||||||
|
console.log(`[SignalStorage] ✅ Loaded session for recipient ${recipientId} (address: ${encodedAddress}, using number key)`);
|
||||||
|
resolve(numData.record);
|
||||||
|
} else {
|
||||||
|
console.warn(`[SignalStorage] ⚠️ Session record missing or invalid for recipient ${recipientId} (address: ${encodedAddress}) - checked both string and number keys`);
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
numRequest.onerror = () => {
|
||||||
|
console.warn(`[SignalStorage] ⚠️ Session record missing for recipient ${recipientId} (address: ${encodedAddress}) - IndexedDB error`);
|
||||||
|
resolve(undefined);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.warn(`[SignalStorage] ⚠️ Session record missing or invalid for recipient ${recipientId} (address: ${encodedAddress}) - no data found`);
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error(`Failed to load session for recipient ${recipientId}:`, request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeSession(encodedAddress: string, record: string): Promise<void> {
|
||||||
|
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
|
||||||
|
// recipientId is the other user's ID, deviceId is always 1 for now
|
||||||
|
const parts = encodedAddress.split(".");
|
||||||
|
const recipientId = parts[0]; // First part is the recipient's user ID
|
||||||
|
|
||||||
|
// Validate record
|
||||||
|
if (!record || typeof record !== "string" || record.length === 0) {
|
||||||
|
console.warn(`[SignalStorage] Invalid session record for address ${encodedAddress}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store with recipientId as the key (using deviceId field for backward compatibility)
|
||||||
|
// Ensure recipientId is stored as string to match how we load it
|
||||||
|
const store = await getStore("sessions", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.put({
|
||||||
|
userId: this.userId,
|
||||||
|
deviceId: String(recipientId), // Store recipientId as string in deviceId field
|
||||||
|
record: record
|
||||||
|
});
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`[SignalStorage] ✅ Stored session for recipient ${recipientId} (address: ${encodedAddress}, record length: ${record.length})`);
|
||||||
|
resolve();
|
||||||
|
// If session sync callback is set and we're not restoring, upload to server in background (non-blocking)
|
||||||
|
// Do this AFTER resolve() to ensure storage completes even if sync fails
|
||||||
|
if (sessionSyncCallback && !isRestoring) {
|
||||||
|
// Use setTimeout to make it truly async and non-blocking
|
||||||
|
setTimeout(() => {
|
||||||
|
sessionSyncCallback!(encodedAddress, record).then(() => {
|
||||||
|
console.log(`Session synced to server for ${encodedAddress}`);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`Failed to sync session to server for ${encodedAddress}:`, err);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSession(encodedAddress: string): Promise<void> {
|
||||||
|
// encodedAddress format: "recipientId.deviceId" (from Signal Protocol)
|
||||||
|
// recipientId is the other user's ID, deviceId is always 1 for now
|
||||||
|
const parts = encodedAddress.split(".");
|
||||||
|
const recipientId = parts[0]; // First part is the recipient's user ID
|
||||||
|
|
||||||
|
// Remove using recipientId as the key (stored in deviceId field for backward compatibility)
|
||||||
|
const store = await getStore("sessions", "readwrite");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const request = store.delete([this.userId, recipientId]);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sessions for this user
|
||||||
|
* Returns array of { address, record } where address is "recipientId.deviceId"
|
||||||
|
* Note: In IndexedDB, deviceId field actually stores the recipientId from the Signal Protocol address
|
||||||
|
*/
|
||||||
|
async getAllSessions(): Promise<Array<{ address: string; record: string }>> {
|
||||||
|
const store = await getStore("sessions");
|
||||||
|
const sessions: Array<{ address: string; record: string }> = [];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.index("userId").openCursor(IDBKeyRange.only(this.userId));
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result;
|
||||||
|
if (cursor) {
|
||||||
|
const data = cursor.value;
|
||||||
|
// In Signal Protocol, address format is "recipientId.deviceId"
|
||||||
|
// We stored it with recipientId in the deviceId field (for backward compatibility)
|
||||||
|
// The actual deviceId is always 1 for now
|
||||||
|
const recipientId = data.deviceId; // This is actually the recipientId from the address
|
||||||
|
const deviceId = 1; // Always 1 for now
|
||||||
|
const address = `${recipientId}.${deviceId}`;
|
||||||
|
|
||||||
|
// Validate that record exists and is a string
|
||||||
|
if (data.record && typeof data.record === "string" && data.record.length > 0) {
|
||||||
|
sessions.push({ address, record: data.record });
|
||||||
|
} else {
|
||||||
|
console.warn(`Invalid session record for recipient ${recipientId}:`, data);
|
||||||
|
}
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
resolve(sessions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,17 @@ export function delay(ms: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
|
export function b64(a: Uint8Array): string {
|
||||||
|
// Use chunked approach to avoid "Maximum call stack size exceeded" for large arrays
|
||||||
|
// Process in chunks and use apply to avoid spreading large arrays
|
||||||
|
const chunkSize = 8192;
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < a.length; i += chunkSize) {
|
||||||
|
const chunk = a.slice(i, i + chunkSize);
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(chunk));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
export function ub64(s: string): Uint8Array {
|
export function ub64(s: string): Uint8Array {
|
||||||
const bin = atob(s);
|
const bin = atob(s);
|
||||||
const arr = new Uint8Array(bin.length);
|
const arr = new Uint8Array(bin.length);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"vite-plugin-sass-dts": "^1.3.34"
|
"vite-plugin-sass-dts": "^1.3.34"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@privacyresearch/libsignal-protocol-typescript": "^0.0.16",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user