ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

View File

@@ -0,0 +1,346 @@
# Kecalek iOS — Architecture & Features
**Version:** 0.8.5
**Platform:** iOS 26+ / Swift 6
**Files:** 57 Swift source files
---
## Project Structure
```
Kecalek/
├── KecalekApp.swift # App entry point, tab navigation
├── AppState.swift # Login state, connection monitoring, reconnection
├── Core/
│ ├── ChatClient.swift # Main actor — all server communication & crypto (3400+ lines)
│ ├── KeyStorage.swift # Persistent key storage (RSA, Ed25519, sessions, TOFU)
│ ├── KeychainService.swift # Secure credential storage (biometric auth)
│ └── MessageCache.swift # Encrypted message cache (per-conversation)
├── Crypto/
│ ├── CryptoUtils.swift # AES-256-GCM, HKDF, chain KDF, local encryption
│ ├── DoubleRatchet.swift # Signal Double Ratchet (DM encryption)
│ ├── X3DH.swift # Extended Triple Diffie-Hellman (session init)
│ ├── SenderKeyState.swift # Sender Key chains (group encryption)
│ ├── Ed25519Crypto.swift # Identity key generation & signing
│ ├── X25519Crypto.swift # DH key agreement & Ed25519↔X25519 conversion
│ ├── RSACrypto.swift # RSA-2048 key generation, PKCS#1/PKCS#8
│ ├── KeyEncryption.swift # ECP1 format: PBKDF2 600K + AES-GCM key encryption
│ ├── FieldArithmetic.swift # GF(2^255-19) for Ed25519→X25519 conversion
│ ├── MessagePadding.swift # Bucket-based padding (64B64KB) for metadata privacy
│ ├── ContactVerification.swift # Fingerprints, safety numbers, QR codes
│ └── CryptoErrors.swift # Error types
├── Network/
│ ├── ConnectionManager.swift # TCP/TLS via Network.framework (actor)
│ └── ProtocolHandler.swift # Newline-delimited JSON encoding/decoding
├── Models/
│ ├── Message.swift # Message, reactions, replies, pins, files, images
│ ├── Conversation.swift # Conversation, members, group detection
│ ├── User.swift # User, UserProfile
│ ├── DeviceBundle.swift # X3DH key bundle per device
│ └── Invitation.swift # Group invitation
├── ViewModels/
│ ├── AuthViewModel.swift # Login, register, pairing, biometrics
│ ├── ChatViewModel.swift # Messages, sending, search, reactions, pins
│ ├── ConversationListVM.swift # Conversations, online users, favorites, avatars
│ ├── ProfileViewModel.swift # Profile editing, avatar upload
│ └── VerificationVM.swift # Safety numbers, QR verification
├── Views/
│ ├── Auth/ # LoginView, RegisterView, PairingView, AuthorizeDeviceView
│ ├── Chat/ # ChatView, MessageBubbleView, MessageInputView, etc.
│ ├── Components/ # CircularAvatarView, ConnectionIndicator, OnlineDotOverlay
│ ├── Conversations/ # ConversationListView, ConversationRowView, NewConversationSheet
│ ├── Groups/ # GroupInfoView, InvitationBanner, CreateGroupSheet
│ ├── Profile/ # ProfileView, EditProfileView
│ └── Verification/ # SafetyNumberView, QRCodeScannerView, VerificationStatusView
└── Utilities/
├── Constants.swift # Version, limits, timeouts, server defaults, crypto params
└── Extensions.swift # Data hex/base64, DateParsing, Dictionary helpers
```
---
## Architecture
### Pattern: MVVM + Actor Isolation
- **Views** — SwiftUI, declarative UI, bind to `@Observable` ViewModels
- **ViewModels** — `@Observable final class`, business logic, async operations
- **ChatClient** — `actor`, single source of truth for all crypto & network ops
- **Models** — plain `struct`s with `Identifiable`, `Codable`
### Concurrency Model
- `ChatClient` is an **actor** — all crypto state (keys, sessions, ratchets) is thread-safe
- All network calls use `async/await`
- Real-time notifications via `AsyncStream<ChatNotification>` (multiple subscribers)
- Background tasks: avatar loading, reconnection, notification listening
### Connection Lifecycle
```
App Launch → Login (RSA challenge-response) → TCP/TLS connected
→ Background listener loop reads messages continuously
→ Notifications broadcast via AsyncStream to all subscribers
→ On disconnect: exponential backoff reconnect (1s → 30s, 5 attempts)
→ On auth failure: immediate logout (keys rotated)
→ On foreground: check connection health, reconnect if stale (>30s)
```
---
## Encryption (Signal Protocol)
### Key Types
| Key | Algorithm | Size | Purpose |
|-----|-----------|------|---------|
| RSA | RSA-2048 | 256B | Login authentication (challenge-response) |
| Identity Key (IK) | Ed25519 | 32B | Long-term identity, signs SPK |
| Signed Pre-Key (SPK) | X25519 | 32B | Medium-term, rotated every 7 days |
| One-Time Pre-Keys (OPKs) | X25519 | 32B each | Single-use, batch of 50, replenish at 20 |
| Ratchet Keys | X25519 | 32B | Ephemeral per DH ratchet step |
| Sender Keys | Random | 32B | Per-group, per-sender chain key |
### DM Encryption (X3DH + Double Ratchet)
1. **Session Init (X3DH):**
- Alice computes: DH(IK_A, SPK_B) || DH(EK_A, IK_B) || DH(EK_A, SPK_B) || DH(EK_A, OPK_B)
- HKDF-SHA256 derives 32-byte shared secret
- Double Ratchet initialized
2. **Message Encryption (Double Ratchet):**
- Root key → chain key → message key (HKDF chain)
- DH ratchet step on each direction change
- AES-256-GCM with derived message key
- AAD: ratchet header (dh_pub, n, pn)
- Max skip: 256 messages
3. **Message Format:**
```
plaintext → MessagePadding.pad() → AES-256-GCM encrypt → base64 → JSON
```
### Group Encryption (Sender Keys)
1. Each member maintains own sender key chain
2. Sender key distributed to all members via pairwise Double Ratchet DMs
3. Messages encrypted with AES-256-GCM using derived chain key
4. Chain ID = SHA-256(sender_key) for verification
5. Max skip: 256 messages per chain
### Message Padding
Bucket sizes: `64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536` bytes
```
Format: 0x01 | plaintext | random_padding | pad_length (4B big-endian)
```
All messages padded to nearest bucket size — prevents metadata analysis of message lengths.
### Self-Encryption
- Derived from identity private key via HKDF
- Encrypts own message copies for multi-device access
- Static key — same across all user's devices
### Contact Verification (TOFU)
- **Fingerprint:** Iterated SHA-512 (5200 rounds) over identity key
- **Safety Number:** 60 digits (12 groups of 5), deterministic ordering by userId
- **QR Code:** Binary `version(1B) + uid_len(1B) + uid + identity_key(32B)`
- **TOFU Registry:** Track first-seen identity keys, alert on change
- **Verification Status:** unverified → trusted (TOFU) → verified (manual/QR)
---
## Features
### Authentication & Accounts
- [x] **Registration** — email + password + verification code
- [x] **Login** — RSA challenge-response authentication
- [x] **Biometric Login** — Face ID / Touch ID via Keychain
- [x] **PoW Challenge** — SHA-256 proof-of-work during registration surge
- [x] **Brute-Force Lockout** — exponential backoff (2^n seconds, max 300s)
- [x] **Change Username** — update display name
- [x] **Change Password** — re-encrypt RSA + Ed25519 keys with new PBKDF2 password
- [x] **Key Rotation** — regenerate all keys with grace period for in-flight sessions
- [x] **Logout** — clean disconnect, clear session
### Multi-Device
- [x] **Device Pairing** — authorize new device via pairing flow
- [x] **Device List** — view all authorized devices
- [x] **Device Removal** — revoke device authorization
- [x] **Self-Encryption** — own messages readable on all devices
### Messaging
- [x] **Text Messages** — encrypted DM and group messages
- [x] **Message Replies** — reply-to with visual indicator
- [x] **Reactions** — 6 emoji reactions (👍❤️😂😮😢👎) with toggle
- [x] **Message Pinning** — pin/unpin with pinned messages sheet
- [x] **Message Deletion** — soft delete with "Message deleted" indicator
- [x] **Message Forwarding** — forward to any conversation with source attribution
- [x] **Message Search** — full-text search with result navigation (prev/next)
- [x] **Read Receipts** — track who read each message
- [x] **Delivery Receipts** — sent → delivered → read indicators (checkmarks)
- [x] **Incremental Sync** — fetch only new messages via `after_ts`
- [x] **Deleted Sync** — `get_deleted_since` for incremental deletion sync
- [x] **Message Padding** — metadata privacy via bucket-based padding
### Media & Files
- [x] **Image Upload** — encrypt + chunked upload (24KB chunks)
- [x] **Image Thumbnails** — base64 JPEG preview inline
- [x] **Image Viewer** — full-screen with pinch zoom
- [x] **File Upload** — any file type with mime detection
- [x] **File Download** — decrypt + share via system share sheet
- [x] **File Icons** — type-based system icons (PDF, DOC, ZIP, etc.)
### Conversations
- [x] **Direct Messages** — 1-on-1 encrypted chat
- [x] **Group Conversations** — multi-member with Sender Keys
- [x] **Create Conversation** — new DM or group
- [x] **Rename Conversation** — group rename (creator only)
- [x] **Delete Conversation** — remove DM or delete group
- [x] **Favorites** — pin conversations to top with star icon
- [x] **Unread Counts** — per-conversation badge
- [x] **Online Status** — real-time presence (green dot)
### Group Management
- [x] **Add Member** — by email
- [x] **Remove Member** — creator only
- [x] **Leave Group** — with confirmation
- [x] **Group Avatar** — upload/change group photo
- [x] **Group Rename** — change group name
- [x] **Group Invitations** — accept/decline with banner UI
### Profile
- [x] **User Profile** — username, email, phone, location
- [x] **Avatar** — upload/change profile photo
- [x] **Field Visibility** — toggle phone/location visibility
- [x] **View Other Profiles** — see other users' info (respects visibility)
### Contact Verification
- [x] **Safety Numbers** — 60-digit verification code per contact pair
- [x] **Fingerprints** — identity key fingerprints
- [x] **QR Code Generation** — generate scannable verification QR
- [x] **QR Code Scanning** — camera-based QR scan for verification
- [x] **Verification Status** — verified (green) / trusted (blue) / unverified (gray)
- [x] **TOFU Registry** — track identity key first-seen, detect changes
- [x] **Shield Icon** — verification badge in chat toolbar
### Connection & Reliability
- [x] **TCP/TLS** — Network.framework with optional TLS
- [x] **Configurable Server** — host, port, TLS toggle in login screen
- [x] **Connection Indicator** — visual status (disconnected/connecting/connected)
- [x] **Auto-Reconnect** — exponential backoff (1s → 30s, 5 attempts)
- [x] **Background/Foreground Handling** — reconnect when returning from background
- [x] **Auth Failure Detection** — immediate logout on key rotation
### Caching & Storage
- [x] **Message Cache** — encrypted per-conversation cache on disk
- [x] **Avatar Cache** — disk + in-memory cache
- [x] **Conversation Cache** — cached list for instant UI
- [x] **Session Persistence** — Double Ratchet states saved encrypted
- [x] **Sender Key Persistence** — group key chains saved encrypted
- [x] **Device Bundle Cache** — 5-minute TTL in-memory
- [x] **Keychain Storage** — biometric-protected credentials
---
## Network Protocol
### Transport
```
TCP → optional TLS → Newline-delimited JSON (\n terminated)
```
### Message Format
```json
{"type": "send_message", "request_id": "uuid", "conversation_id": "...", "ciphertext": "base64..."}
```
### API Methods (36 endpoints)
**Auth:** `register`, `register_confirm`, `login_start`, `login_finish`, `change_username`, `change_password`
**Keys:** `get_key_bundle`, `ensure_prekeys`, `get_prekey_count`, `rotate_keys`, `reset_session`
**Messaging:** `send_message`, `get_messages`, `delete_message`, `mark_read`, `mark_conversation_read`, `react_message`, `pin_message`, `get_pinned_messages`, `get_deleted_since`, `forward_message`, `confirm_delivery`, `search_messages`
**Conversations:** `list_conversations`, `create_conversation`, `find_conversation`, `delete_conversation`, `rename_conversation`, `add_member`, `remove_member`, `leave_group`, `accept_invitation`, `decline_invitation`, `list_invitations`
**Profiles:** `get_profile`, `update_profile`, `update_avatar`, `get_avatar`, `update_group_avatar`, `get_group_avatar`
**Files:** `upload_file`, `download_file`
**Devices:** `list_devices`, `remove_device`, `pairing_start`, `pairing_wait`, `authorize_device`
### Notification Types (17 real-time events)
```
new_message, messages_read, message_deleted, message_reacted,
message_pinned, message_unpinned, message_delivered,
conversation_created, conversation_renamed, conversation_deleted,
member_added, member_removed, group_invitation,
user_online, user_offline, online_users,
session_reset, keys_updated
```
---
## Storage Layout
```
~/Library/Application Support/EncryptedChat/{email}/
├── private.pem # RSA private key (password-protected)
├── public.pem # RSA public key
├── identity_private.bin # Ed25519 private (ECP1: PBKDF2 + AES-GCM)
├── identity_public.bin # Ed25519 public
├── spk_private.bin # Current signed pre-key (X25519)
├── spk_id.txt # SPK ID
├── prevspk_private.bin # Previous SPK (grace period)
├── prevspk_id.txt
├── opk_{id}.bin # One-time pre-keys
├── sessions/
│ └── {userId}_{deviceId}.bin # Double Ratchet state (encrypted)
├── sender_keys/
│ └── {convId}_{senderId}_{deviceId}.bin # Sender Key chain (encrypted)
├── message_cache/
│ └── {convId}.json # Message cache (encrypted)
├── conversations_cache.json # Conversation list cache (encrypted)
├── avatars/
│ └── {convId}.bin # Avatar image data (encrypted)
├── known_identity_keys.bin # TOFU registry (encrypted)
├── verified_contacts.bin # Verified contacts (encrypted)
└── favorites.bin # Favorite conversation IDs (encrypted)
```
---
## Build
```bash
# Xcode build
open /Users/filip/Desktop/kecalek_ios/Kecalek/Kecalek.xcodeproj
# Command-line build
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild -scheme Kecalek \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
build
```
**Result:** 57 files, 0 errors, 0 warnings.

View File

@@ -0,0 +1,351 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
DC2D11D32F3CE6FD009F93FA /* Kecalek.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kecalek.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
DC2D11D52F3CE6FD009F93FA /* Kecalek */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Kecalek;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
DC2D11D02F3CE6FD009F93FA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
DC2D11CA2F3CE6FD009F93FA = {
isa = PBXGroup;
children = (
DC2D11D52F3CE6FD009F93FA /* Kecalek */,
DC2D11D42F3CE6FD009F93FA /* Products */,
);
sourceTree = "<group>";
};
DC2D11D42F3CE6FD009F93FA /* Products */ = {
isa = PBXGroup;
children = (
DC2D11D32F3CE6FD009F93FA /* Kecalek.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
DC2D11D22F3CE6FD009F93FA /* Kecalek */ = {
isa = PBXNativeTarget;
buildConfigurationList = DC2D11DE2F3CE6FF009F93FA /* Build configuration list for PBXNativeTarget "Kecalek" */;
buildPhases = (
DC2D11CF2F3CE6FD009F93FA /* Sources */,
DC2D11D02F3CE6FD009F93FA /* Frameworks */,
DC2D11D12F3CE6FD009F93FA /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
DC2D11D52F3CE6FD009F93FA /* Kecalek */,
);
name = Kecalek;
packageProductDependencies = (
);
productName = Kecalek;
productReference = DC2D11D32F3CE6FD009F93FA /* Kecalek.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
DC2D11CB2F3CE6FD009F93FA /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2620;
TargetAttributes = {
DC2D11D22F3CE6FD009F93FA = {
CreatedOnToolsVersion = 26.0.1;
};
};
};
buildConfigurationList = DC2D11CE2F3CE6FD009F93FA /* Build configuration list for PBXProject "Kecalek" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = DC2D11CA2F3CE6FD009F93FA;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = DC2D11D42F3CE6FD009F93FA /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
DC2D11D22F3CE6FD009F93FA /* Kecalek */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
DC2D11D12F3CE6FD009F93FA /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
DC2D11CF2F3CE6FD009F93FA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
DC2D11DC2F3CE6FF009F93FA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = J26GZ5AW57;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
DC2D11DD2F3CE6FF009F93FA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = J26GZ5AW57;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
DC2D11DF2F3CE6FF009F93FA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = J26GZ5AW57;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Kecalek;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR codes for contact verification";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Sign in with Face ID";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos in chat";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Kecalek.lockmseg.com2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
DC2D11E02F3CE6FF009F93FA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = J26GZ5AW57;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Kecalek;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR codes for contact verification";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Sign in with Face ID";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos in chat";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Kecalek.lockmseg.com2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
DC2D11CE2F3CE6FD009F93FA /* Build configuration list for PBXProject "Kecalek" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DC2D11DC2F3CE6FF009F93FA /* Debug */,
DC2D11DD2F3CE6FF009F93FA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
DC2D11DE2F3CE6FF009F93FA /* Build configuration list for PBXNativeTarget "Kecalek" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DC2D11DF2F3CE6FF009F93FA /* Debug */,
DC2D11E02F3CE6FF009F93FA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = DC2D11CB2F3CE6FD009F93FA /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildLocationStyle</key>
<string>UseAppPreferences</string>
<key>CompilationCachingSetting</key>
<string>Default</string>
<key>CustomBuildLocationType</key>
<string>RelativeToDerivedData</string>
<key>DerivedDataLocationStyle</key>
<string>Default</string>
<key>ShowSharedSchemesAutomaticallyEnabled</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Kecalek.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

BIN
ios_client 0.8.5/Kecalek/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,161 @@
import Foundation
import SwiftUI
enum ConnectionStatus: Equatable {
case disconnected
case connecting
case connected
case reconnecting
}
@Observable
final class AppState {
var isLoggedIn = false
var currentUser: User?
var connectionStatus: ConnectionStatus = .disconnected
var email: String = ""
let chatClient = ChatClient()
private var reconnectTask: Task<Void, Never>?
private var notificationTask: Task<Void, Never>?
private var isReconnecting = false
private var backgroundedAt: Date?
/// Start listening for connection state changes (call after login)
func startConnectionMonitor() {
notificationTask?.cancel()
notificationTask = Task { [weak self] in
guard let self else { return }
let stream = await chatClient.makeNotificationStream()
for await notification in stream {
guard !Task.isCancelled else { break }
if case .connectionStateChanged(let connected) = notification {
await MainActor.run {
if connected {
self.connectionStatus = .connected
self.isReconnecting = false
self.reconnectTask?.cancel()
self.reconnectTask = nil
} else if self.isLoggedIn, !self.isReconnecting {
// Only start reconnect if not already reconnecting
// (reconnect() internally calls disconnect() which fires this)
self.connectionStatus = .disconnected
self.attemptReconnect()
}
}
}
}
}
}
/// Attempt reconnect with exponential backoff; immediate logout on auth failure
@MainActor
private func attemptReconnect() {
reconnectTask?.cancel()
isReconnecting = true
reconnectTask = Task { [weak self] in
guard let self else { return }
let maxAttempts = 5
var delay: TimeInterval = Constants.reconnectBaseDelay
for attempt in 1...maxAttempts {
guard !Task.isCancelled, self.isLoggedIn else { return }
self.connectionStatus = .reconnecting
#if DEBUG
print("DEBUG AppState: reconnect attempt \(attempt)/\(maxAttempts), delay=\(delay)s")
#endif
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled, self.isLoggedIn else { return }
let result = await self.chatClient.reconnect()
switch result {
case .success:
self.connectionStatus = .connected
self.isReconnecting = false
#if DEBUG
print("DEBUG AppState: reconnected on attempt \(attempt)")
#endif
return
case .authFailed:
// Keys rotated or invalid logout immediately, don't retry
self.isReconnecting = false
#if DEBUG
print("DEBUG AppState: auth failed (keys likely rotated), logging out immediately")
#endif
await self.logout()
return
case .networkError:
// Network issue retry with backoff
delay = min(delay * 2, Constants.reconnectMaxDelay)
}
}
// All network retries exhausted force logout
self.isReconnecting = false
guard !Task.isCancelled, self.isLoggedIn else { return }
#if DEBUG
print("DEBUG AppState: reconnect failed after \(maxAttempts) attempts, logging out")
#endif
await self.logout()
}
}
// MARK: - App Lifecycle
func handleEnteredBackground() {
backgroundedAt = Date()
}
@MainActor
func handleBecameActive() {
guard isLoggedIn, !isReconnecting else { return }
let wasInBackground = backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0
backgroundedAt = nil
Task {
let alive = await chatClient.isConnectionAlive()
if !alive {
#if DEBUG
print("DEBUG AppState: foreground — connection dead, reconnecting")
#endif
await MainActor.run {
guard !self.isReconnecting else { return }
self.connectionStatus = .reconnecting
self.attemptReconnect()
}
} else if wasInBackground > 30 {
// Connection appears alive but was backgrounded a long time
// force reconnect to ensure fresh state
#if DEBUG
print("DEBUG AppState: foreground — stale connection (\(Int(wasInBackground))s), reconnecting")
#endif
await MainActor.run {
guard !self.isReconnecting else { return }
self.connectionStatus = .reconnecting
self.attemptReconnect()
}
} else {
#if DEBUG
print("DEBUG AppState: foreground — connection alive (\(Int(wasInBackground))s in bg)")
#endif
}
}
}
func logout() async {
isReconnecting = false
reconnectTask?.cancel()
reconnectTask = nil
notificationTask?.cancel()
notificationTask = nil
await chatClient.disconnect()
KeychainService.deleteCredentials()
isLoggedIn = false
currentUser = nil
connectionStatus = .disconnected
email = ""
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 KiB

View File

@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
import Foundation
import CryptoKit
/// Local file storage for keys, sessions, and sender keys.
/// Matches Python: chat_core.py key storage functions.
///
/// Base directory: Application Support / EncryptedChat / {email}
/// Same file names as Python client for cross-platform compatibility.
enum KeyStorage {
// MARK: - Base Directory
/// Get or create the key storage directory for a user
static func getKeyDir(email: String) throws -> URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dir = appSupport.appendingPathComponent("EncryptedChat").appendingPathComponent(email)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
// iOS file protection
try (dir as NSURL).setResourceValue(URLFileProtection.complete, forKey: .fileProtectionKey)
return dir
}
// MARK: - RSA Keys
/// Save RSA keypair
static func saveRSAKeys(email: String, privateKey: SecKey, publicKey: SecKey, password: Data? = nil) throws {
let dir = try getKeyDir(email: email)
let privData = try RSACrypto.serializePrivateKey(privateKey, password: password)
let pubData = try RSACrypto.serializePublicKey(publicKey)
try writeProtected(privData, to: dir.appendingPathComponent("private.pem"))
try writeProtected(pubData, to: dir.appendingPathComponent("public.pem"))
}
/// Load RSA keypair. Returns (private, public, error).
static func loadRSAKeys(email: String, password: Data? = nil) -> (SecKey?, SecKey?, String?) {
guard let dir = try? getKeyDir(email: email) else {
return (nil, nil, "Cannot access key directory")
}
let privPath = dir.appendingPathComponent("private.pem")
let pubPath = dir.appendingPathComponent("public.pem")
guard FileManager.default.fileExists(atPath: privPath.path) else {
return (nil, nil, "No local keys found.")
}
guard let privData = try? Data(contentsOf: privPath),
let pubData = try? Data(contentsOf: pubPath) else {
return (nil, nil, "Cannot read key files.")
}
do {
let privateKey = try RSACrypto.loadPrivateKey(privData, password: password)
let publicKey = try RSACrypto.loadPublicKey(pubData)
return (privateKey, publicKey, nil)
} catch {
// Try without password (unencrypted)
do {
let privateKey = try RSACrypto.loadPrivateKey(privData, password: nil)
let publicKey = try RSACrypto.loadPublicKey(pubData)
// Re-save with password if provided
if let password = password {
try? saveRSAKeys(email: email, privateKey: privateKey, publicKey: publicKey, password: password)
}
return (privateKey, publicKey, nil)
} catch {
return (nil, nil, "Invalid or missing password.")
}
}
}
// MARK: - Identity Keys (Ed25519)
static func saveIdentityKeys(
email: String,
privateKey: Curve25519.Signing.PrivateKey,
publicKey: Curve25519.Signing.PublicKey,
password: Data? = nil
) throws {
let dir = try getKeyDir(email: email)
let privData = try Ed25519Crypto.serializePrivate(privateKey, password: password)
let pubData = Ed25519Crypto.serializePublic(publicKey)
try writeProtected(privData, to: dir.appendingPathComponent("identity_private.bin"))
try writeProtected(pubData, to: dir.appendingPathComponent("identity_public.bin"))
}
static func loadIdentityKeys(
email: String,
password: Data? = nil
) -> (Curve25519.Signing.PrivateKey?, Curve25519.Signing.PublicKey?) {
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
let privPath = dir.appendingPathComponent("identity_private.bin")
let pubPath = dir.appendingPathComponent("identity_public.bin")
guard FileManager.default.fileExists(atPath: privPath.path),
let privData = try? Data(contentsOf: privPath),
let pubData = try? Data(contentsOf: pubPath) else {
return (nil, nil)
}
do {
let priv = try Ed25519Crypto.loadPrivate(privData, password: password)
let pub = try Ed25519Crypto.loadPublic(pubData)
return (priv, pub)
} catch {
return (nil, nil)
}
}
// MARK: - Signed Pre-Key
static func saveSPK(email: String, privateKey: Curve25519.KeyAgreement.PrivateKey, spkId: String) throws {
let dir = try getKeyDir(email: email)
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("spk_private.bin"))
try writeProtected(Data(spkId.utf8), to: dir.appendingPathComponent("spk_id.txt"))
}
static func loadSPK(email: String) -> (Curve25519.KeyAgreement.PrivateKey?, String?) {
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
let privPath = dir.appendingPathComponent("spk_private.bin")
let idPath = dir.appendingPathComponent("spk_id.txt")
guard FileManager.default.fileExists(atPath: privPath.path),
let privData = try? Data(contentsOf: privPath),
let priv = try? X25519Crypto.loadPrivate(privData) else {
return (nil, nil)
}
let spkId = (try? String(data: Data(contentsOf: idPath), encoding: .utf8))?.trimmed ?? ""
return (priv, spkId)
}
// MARK: - Previous SPK (Grace Period)
static func savePrevSPK(email: String, privateKey: Curve25519.KeyAgreement.PrivateKey, spkId: String) throws {
let dir = try getKeyDir(email: email)
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("prev_spk_private.bin"))
try writeProtected(Data(spkId.utf8), to: dir.appendingPathComponent("prev_spk_id.txt"))
}
static func loadPrevSPK(email: String) -> (Curve25519.KeyAgreement.PrivateKey?, String?) {
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
let privPath = dir.appendingPathComponent("prev_spk_private.bin")
let idPath = dir.appendingPathComponent("prev_spk_id.txt")
guard FileManager.default.fileExists(atPath: privPath.path),
let privData = try? Data(contentsOf: privPath),
let priv = try? X25519Crypto.loadPrivate(privData) else {
return (nil, nil)
}
let spkId = (try? String(data: Data(contentsOf: idPath), encoding: .utf8))?.trimmed ?? ""
return (priv, spkId)
}
// MARK: - One-Time Pre-Keys
static func saveOPKPrivate(email: String, opkId: String, privateKey: Curve25519.KeyAgreement.PrivateKey) throws {
let dir = try getKeyDir(email: email).appendingPathComponent("opk_private")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("\(opkId).bin"))
}
static func loadOPKPrivate(email: String, opkId: String) -> Curve25519.KeyAgreement.PrivateKey? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("opk_private").appendingPathComponent("\(opkId).bin")
guard let data = try? Data(contentsOf: path) else { return nil }
return try? X25519Crypto.loadPrivate(data)
}
static func deleteOPKPrivate(email: String, opkId: String) {
guard let dir = try? getKeyDir(email: email) else { return }
let path = dir.appendingPathComponent("opk_private").appendingPathComponent("\(opkId).bin")
try? FileManager.default.removeItem(at: path)
}
// MARK: - Device ID
static func saveDeviceId(email: String, deviceId: String) throws {
let dir = try getKeyDir(email: email)
try writeProtected(Data(deviceId.utf8), to: dir.appendingPathComponent("device_id.txt"))
}
static func loadDeviceId(email: String) -> String? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("device_id.txt")
guard let data = try? Data(contentsOf: path) else { return nil }
let str = String(data: data, encoding: .utf8)?.trimmed
return (str?.isEmpty ?? true) ? nil : str
}
// MARK: - Sessions (Double Ratchet)
static func saveSession(
email: String,
peerUserId: String,
ratchet: DoubleRatchet,
localKey: Data? = nil,
peerDeviceId: String? = nil
) throws {
let dir = try getKeyDir(email: email).appendingPathComponent("sessions")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let filename: String
if let deviceId = peerDeviceId {
filename = "\(peerUserId)_\(deviceId).bin"
} else {
filename = "\(peerUserId).bin"
}
let exported = try ratchet.exportState()
guard let localKey = localKey else {
throw CryptoError.encryptionFailed("localKey required for session storage")
}
let data = try CryptoUtils.encryptLocal(exported, key: localKey)
try writeProtected(data, to: dir.appendingPathComponent(filename))
}
static func loadSession(
email: String,
peerUserId: String,
localKey: Data? = nil,
peerDeviceId: String? = nil
) -> DoubleRatchet? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let sessionsDir = dir.appendingPathComponent("sessions")
let filename: String
if let deviceId = peerDeviceId {
filename = "\(peerUserId)_\(deviceId).bin"
} else {
filename = "\(peerUserId).bin"
}
let path = sessionsDir.appendingPathComponent(filename)
return loadSessionFile(path, localKey: localKey)
}
static func deleteSession(email: String, peerUserId: String, peerDeviceId: String? = nil) {
guard let dir = try? getKeyDir(email: email) else { return }
let sessionsDir = dir.appendingPathComponent("sessions")
if let deviceId = peerDeviceId {
let path = sessionsDir.appendingPathComponent("\(peerUserId)_\(deviceId).bin")
try? FileManager.default.removeItem(at: path)
} else {
// Delete all sessions for this user
if let files = try? FileManager.default.contentsOfDirectory(atPath: sessionsDir.path) {
for file in files where file.hasPrefix(peerUserId) {
try? FileManager.default.removeItem(at: sessionsDir.appendingPathComponent(file))
}
}
}
}
private static func loadSessionFile(_ path: URL, localKey: Data?) -> DoubleRatchet? {
guard let raw = try? Data(contentsOf: path) else { return nil }
if let localKey = localKey {
// Try encrypted first
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
return try? DoubleRatchet.importState(decrypted)
}
// Migration: try plaintext, immediately re-encrypt
if let ratchet = try? DoubleRatchet.importState(raw) {
try? writeProtected(CryptoUtils.encryptLocal(try ratchet.exportState(), key: localKey), to: path)
return ratchet
}
// Corrupted delete
try? FileManager.default.removeItem(at: path)
return nil
}
// No localKey refuse to load plaintext sessions
return nil
}
// MARK: - Sender Keys
static func saveSenderKeyState(
email: String,
convId: String,
state: SenderKeyState,
localKey: Data? = nil
) throws {
let dir = try getKeyDir(email: email).appendingPathComponent("sender_keys")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
guard let localKey = localKey else {
throw CryptoError.encryptionFailed("localKey required for sender key storage")
}
let data = try CryptoUtils.encryptLocal(state.exportState(), key: localKey)
try writeProtected(data, to: dir.appendingPathComponent("\(convId).bin"))
}
static func loadSenderKeyState(
email: String,
convId: String,
localKey: Data? = nil
) -> SenderKeyState? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("sender_keys").appendingPathComponent("\(convId).bin")
guard let raw = try? Data(contentsOf: path) else { return nil }
if let localKey = localKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
return try? SenderKeyState.importState(decrypted)
}
// Migration: try plaintext, immediately re-encrypt
if let state = try? SenderKeyState.importState(raw) {
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
return state
}
try? FileManager.default.removeItem(at: path)
return nil
}
return nil
}
static func deleteSenderKeyState(email: String, convId: String) {
guard let dir = try? getKeyDir(email: email) else { return }
let path = dir.appendingPathComponent("sender_keys").appendingPathComponent("\(convId).bin")
try? FileManager.default.removeItem(at: path)
}
// MARK: - Received Sender Keys
static func saveRecvSenderKey(
email: String,
convId: String,
senderId: String,
senderDeviceId: String,
state: SenderKeyState,
localKey: Data? = nil
) throws {
let dir = try getKeyDir(email: email).appendingPathComponent("sender_keys_recv")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
guard let localKey = localKey else {
throw CryptoError.encryptionFailed("localKey required for sender key storage")
}
let data = try CryptoUtils.encryptLocal(state.exportState(), key: localKey)
try writeProtected(data, to: dir.appendingPathComponent("\(convId)_\(senderId)_\(senderDeviceId).bin"))
}
static func loadRecvSenderKey(
email: String,
convId: String,
senderId: String,
senderDeviceId: String,
localKey: Data? = nil
) -> SenderKeyState? {
guard let dir = try? getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("sender_keys_recv").appendingPathComponent("\(convId)_\(senderId)_\(senderDeviceId).bin")
guard let raw = try? Data(contentsOf: path) else { return nil }
if let localKey = localKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
return try? SenderKeyState.importState(decrypted)
}
// Migration: try plaintext, immediately re-encrypt
if let state = try? SenderKeyState.importState(raw) {
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
return state
}
try? FileManager.default.removeItem(at: path)
return nil
}
return nil
}
static func deleteRecvSenderKeys(email: String, convId: String) {
guard let dir = try? getKeyDir(email: email) else { return }
let recvDir = dir.appendingPathComponent("sender_keys_recv")
guard let files = try? FileManager.default.contentsOfDirectory(atPath: recvDir.path) else { return }
for file in files where file.hasPrefix(convId) {
try? FileManager.default.removeItem(at: recvDir.appendingPathComponent(file))
}
}
// MARK: - Favorites
static func saveFavorites(email: String, favorites: Set<String>, localKey: Data? = nil) throws {
let dir = try getKeyDir(email: email)
let jsonData = try JSONSerialization.data(withJSONObject: Array(favorites))
let dataToWrite: Data
if let localKey = localKey {
dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: localKey)
} else {
dataToWrite = jsonData
}
try writeProtected(dataToWrite, to: dir.appendingPathComponent("favorites.json"))
}
static func loadFavorites(email: String, localKey: Data? = nil) -> Set<String> {
guard let dir = try? getKeyDir(email: email) else { return [] }
let path = dir.appendingPathComponent("favorites.json")
guard let raw = try? Data(contentsOf: path) else { return [] }
let jsonData: Data
if let localKey = localKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
jsonData = decrypted
} else {
jsonData = raw // migration fallback
}
} else {
jsonData = raw
}
guard let array = try? JSONSerialization.jsonObject(with: jsonData) as? [String] else {
return []
}
return Set(array)
}
// MARK: - TOFU Identity Key Registry
static func saveKnownIdentityKeys(email: String, keys: [String: [String: String]], localKey: Data?) throws {
let dir = try getKeyDir(email: email)
let jsonObj: [String: Any] = ["version": 1, "keys": keys]
let jsonData = try JSONSerialization.data(withJSONObject: jsonObj)
guard let localKey = localKey else {
try writeProtected(jsonData, to: dir.appendingPathComponent("known_identity_keys.bin"))
return
}
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: localKey)
try writeProtected(encrypted, to: dir.appendingPathComponent("known_identity_keys.bin"))
}
static func loadKnownIdentityKeys(email: String, localKey: Data?) -> [String: [String: String]] {
guard let dir = try? getKeyDir(email: email) else { return [:] }
let path = dir.appendingPathComponent("known_identity_keys.bin")
guard let raw = try? Data(contentsOf: path) else { return [:] }
do {
let jsonData: Data
if let localKey = localKey {
jsonData = try CryptoUtils.decryptLocal(raw, key: localKey)
} else {
jsonData = raw
}
guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let keys = obj["keys"] as? [String: [String: String]] else { return [:] }
return keys
} catch {
return [:]
}
}
// MARK: - Verified Contacts
static func saveVerifiedContacts(email: String, contacts: [String: [String: String]], localKey: Data?) throws {
let dir = try getKeyDir(email: email)
let jsonObj: [String: Any] = ["version": 1, "contacts": contacts]
let jsonData = try JSONSerialization.data(withJSONObject: jsonObj)
guard let localKey = localKey else {
try writeProtected(jsonData, to: dir.appendingPathComponent("verified_contacts.bin"))
return
}
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: localKey)
try writeProtected(encrypted, to: dir.appendingPathComponent("verified_contacts.bin"))
}
static func loadVerifiedContacts(email: String, localKey: Data?) -> [String: [String: String]] {
guard let dir = try? getKeyDir(email: email) else { return [:] }
let path = dir.appendingPathComponent("verified_contacts.bin")
guard let raw = try? Data(contentsOf: path) else { return [:] }
do {
let jsonData: Data
if let localKey = localKey {
jsonData = try CryptoUtils.decryptLocal(raw, key: localKey)
} else {
jsonData = raw
}
guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let contacts = obj["contacts"] as? [String: [String: String]] else { return [:] }
return contacts
} catch {
return [:]
}
}
// MARK: - Helpers
private static func writeProtected(_ data: Data, to url: URL) throws {
try data.write(to: url, options: .completeFileProtection)
}
}

View File

@@ -0,0 +1,132 @@
import Foundation
import Security
import LocalAuthentication
enum KeychainService {
private static let service = "com.encryptedchat.credentials"
private static let account = "userCredentials"
struct Credentials: Codable {
let email: String
let password: String
let host: String
let port: UInt16
}
/// Check if saved credentials exist without triggering biometric prompt.
static func hasSavedCredentials() -> Bool {
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
// errSecInteractionNotAllowed means item exists but needs biometric
return status == errSecSuccess || status == errSecInteractionNotAllowed
}
/// Save credentials to Keychain with biometric protection.
static func saveCredentials(email: String, password: String, host: String, port: UInt16) throws {
// Delete any existing entry first
deleteCredentials()
let credentials = Credentials(email: email, password: password, host: host, port: port)
let data = try JSONEncoder().encode(credentials)
var accessError: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryAny,
&accessError
) else {
throw KeychainError.accessControlCreationFailed
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
/// Load credentials from Keychain. Triggers biometric prompt.
static func loadCredentials() throws -> Credentials {
let context = LAContext()
context.localizedReason = "Unlock to log in"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status == errSecUserCanceled || status == errSecAuthFailed {
throw KeychainError.biometricFailed
}
throw KeychainError.loadFailed(status)
}
return try JSONDecoder().decode(Credentials.self, from: data)
}
/// Delete stored credentials from Keychain.
@discardableResult
static func deleteCredentials() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
/// Check if biometric authentication is available on this device.
static func isBiometricAvailable() -> Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
enum KeychainError: LocalizedError {
case accessControlCreationFailed
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case biometricFailed
var errorDescription: String? {
switch self {
case .accessControlCreationFailed:
return "Failed to create biometric access control"
case .saveFailed(let status):
return "Failed to save credentials (error \(status))"
case .loadFailed(let status):
return "Failed to load credentials (error \(status))"
case .biometricFailed:
return "Biometric authentication failed or was cancelled"
}
}
}
}

View File

@@ -0,0 +1,200 @@
import Foundation
/// Encrypted local message cache.
/// Matches Python: chat_core.py message cache (message_cache/{conv_id}.json)
enum MessageCache {
/// Save messages for a conversation (encrypted with local storage key)
static func save(email: String, convId: String, messages: [[String: Any]], cacheKey: Data?) throws {
let dir = try KeyStorage.getKeyDir(email: email).appendingPathComponent("message_cache")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let jsonData = try JSONSerialization.data(withJSONObject: messages)
guard let cacheKey = cacheKey else {
return // Refuse to save plaintext message cache
}
let dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: cacheKey)
try dataToWrite.write(to: dir.appendingPathComponent("\(convId).json"), options: .completeFileProtection)
}
/// Load messages for a conversation
static func load(email: String, convId: String, cacheKey: Data?) -> [[String: Any]]? {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json")
guard let raw = try? Data(contentsOf: path) else { return nil }
let jsonData: Data
if let cacheKey = cacheKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
jsonData = decrypted
} else if let parsed = try? JSONSerialization.jsonObject(with: raw) as? [[String: Any]] {
// Migration: re-encrypt plaintext cache and return
try? save(email: email, convId: convId, messages: parsed, cacheKey: cacheKey)
return parsed
} else {
// Corrupted delete stale cache
try? FileManager.default.removeItem(at: path)
return nil
}
} else {
jsonData = raw
}
return try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]]
}
/// Search messages in a conversation
static func search(email: String, convId: String, query: String, cacheKey: Data?) -> [[String: Any]] {
guard let messages = load(email: email, convId: convId, cacheKey: cacheKey) else {
return []
}
let lowerQuery = query.lowercased()
return messages.filter { msg in
if let text = msg["text"] as? String, text.lowercased().contains(lowerQuery) {
return true
}
return false
}
}
/// Delete cache for a conversation
static func delete(email: String, convId: String) {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json")
try? FileManager.default.removeItem(at: path)
}
// MARK: - Per-Message Cache (for Double Ratchet - messages can only be decrypted once)
/// Cache a decrypted message by its ID
static func cacheDecryptedMessage(email: String, convId: String, messageId: String, plaintext: Data, cacheKey: Data?) {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
let path = cacheDir.appendingPathComponent("\(messageId).bin")
do {
guard let cacheKey = cacheKey else { return } // Refuse plaintext
let dataToWrite = try CryptoUtils.encryptLocal(plaintext, key: cacheKey)
try dataToWrite.write(to: path, options: .completeFileProtection)
} catch {
#if DEBUG
print("DEBUG MessageCache: failed to cache message \(messageId): \(error)")
#endif
}
}
/// Load all cached decrypted messages for a conversation.
/// Returns array of (messageId, plaintext) tuples.
static func loadAllCachedMessages(email: String, convId: String, cacheKey: Data?) -> [(String, Data)] {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [] }
let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId)
guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [] }
var result: [(String, Data)] = []
for file in files where file.pathExtension == "bin" {
let messageId = file.deletingPathExtension().lastPathComponent
guard let raw = try? Data(contentsOf: file) else { continue }
if let cacheKey = cacheKey,
let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
result.append((messageId, decrypted))
} else if cacheKey == nil {
result.append((messageId, raw))
}
}
return result
}
/// Get a cached decrypted message by ID
static func getCachedMessage(email: String, convId: String, messageId: String, cacheKey: Data?) -> Data? {
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId).appendingPathComponent("\(messageId).bin")
guard let raw = try? Data(contentsOf: path) else { return nil }
if let cacheKey = cacheKey {
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
return decrypted
}
// Migration: try as plaintext, re-encrypt
if let _ = try? JSONSerialization.jsonObject(with: raw) {
cacheDecryptedMessage(email: email, convId: convId, messageId: messageId, plaintext: raw, cacheKey: cacheKey)
return raw
}
// Corrupted delete
try? FileManager.default.removeItem(at: path)
return nil
}
return raw
}
// MARK: - Conversation List Cache
/// Save conversation list to disk (encrypted with local key)
static func saveConversations(email: String, conversations: [Conversation], cacheKey: Data?) {
guard let cacheKey = cacheKey else { return }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
do {
let jsonData = try JSONEncoder().encode(conversations)
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: cacheKey)
try encrypted.write(to: dir.appendingPathComponent("conversation_cache.json"), options: .completeFileProtection)
} catch {
#if DEBUG
print("DEBUG MessageCache: failed to save conversations: \(error)")
#endif
}
}
/// Load conversation list from disk
static func loadConversations(email: String, cacheKey: Data?) -> [Conversation]? {
guard let cacheKey = cacheKey else { return nil }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("conversation_cache.json")
guard let raw = try? Data(contentsOf: path) else { return nil }
guard let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { return nil }
return try? JSONDecoder().decode([Conversation].self, from: decrypted)
}
// MARK: - Avatar Disk Cache
/// Save avatar data to disk (encrypted with local key)
static func saveAvatar(email: String, key: String, data: Data, cacheKey: Data?) {
guard let cacheKey = cacheKey else { return }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
let cacheDir = dir.appendingPathComponent("avatar_cache")
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
do {
let encrypted = try CryptoUtils.encryptLocal(data, key: cacheKey)
try encrypted.write(to: cacheDir.appendingPathComponent("\(key).dat"), options: .completeFileProtection)
} catch {
#if DEBUG
print("DEBUG MessageCache: failed to save avatar \(key): \(error)")
#endif
}
}
/// Load avatar data from disk
static func loadAvatar(email: String, key: String, cacheKey: Data?) -> Data? {
guard let cacheKey = cacheKey else { return nil }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
let path = dir.appendingPathComponent("avatar_cache").appendingPathComponent("\(key).dat")
guard let raw = try? Data(contentsOf: path) else { return nil }
return try? CryptoUtils.decryptLocal(raw, key: cacheKey)
}
/// Load all cached avatars from disk
static func loadAllAvatars(email: String, cacheKey: Data?) -> [String: Data] {
guard let cacheKey = cacheKey else { return [:] }
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [:] }
let cacheDir = dir.appendingPathComponent("avatar_cache")
guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [:] }
var result: [String: Data] = [:]
for file in files where file.pathExtension == "dat" {
let key = file.deletingPathExtension().lastPathComponent
guard let raw = try? Data(contentsOf: file),
let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { continue }
result[key] = decrypted
}
return result
}
}

View File

@@ -0,0 +1,162 @@
import Foundation
import CryptoKit
/// Contact key verification: fingerprints, safety numbers, QR codes.
/// Matches Python: crypto_utils.py compute_fingerprint, compute_safety_number, etc.
enum ContactVerification {
/// Version byte for fingerprint computation (Signal's NumericFingerprint).
private static let fingerprintVersion: UInt16 = 0
/// Number of SHA-512 iterations for fingerprint computation.
private static let fingerprintIterations = 5200
// MARK: - Fingerprint
/// Compute a 32-byte fingerprint for a user's identity key.
///
/// Uses iterated SHA-512 (Signal's NumericFingerprint algorithm).
/// Seed: version(2B big-endian) + identity_key(32B) + user_id(UTF-8).
/// Each iteration: SHA-512(previous_hash + identity_key).
/// Output: first 32 bytes of final hash.
static func computeFingerprint(userId: String, identityKey: Data, iterations: Int = fingerprintIterations) -> Data {
let versionBytes = fingerprintVersion.bigEndianData
var data = versionBytes + identityKey + Data(userId.utf8)
for _ in 0..<iterations {
var hasher = SHA512()
hasher.update(data: data)
hasher.update(data: identityKey)
let digest = hasher.finalize()
data = Data(digest)
}
return Data(data.prefix(32))
}
/// Format 32-byte fingerprint as 6 groups of 5 zero-padded digits (30 digits).
///
/// Each group: int(bytes[i*5:(i+1)*5], big-endian) % 100000.
/// Output: two lines of 3 groups each, space-separated.
static func formatFingerprint(_ fpBytes: Data) -> String {
var groups: [String] = []
for i in 0..<6 {
let start = i * 5
let end = min(start + 5, fpBytes.count)
let slice = fpBytes[fpBytes.startIndex + start ..< fpBytes.startIndex + end]
let num = bigEndianUInt64(slice) % 100000
groups.append(String(format: "%05d", num))
}
return groups[0..<3].joined(separator: " ") + "\n" + groups[3..<6].joined(separator: " ")
}
// MARK: - Safety Number
/// Compute a 60-digit safety number for a pair of users.
///
/// Both users see the same number regardless of who computes it.
/// Lower user_id's fingerprint comes first (deterministic ordering).
/// Output: 12 groups of 5 digits, formatted as 3 lines of 4 groups.
static func computeSafetyNumber(
myUserId: String, myIdentityKey: Data,
theirUserId: String, theirIdentityKey: Data
) -> String {
let fpMine = computeFingerprint(userId: myUserId, identityKey: myIdentityKey)
let fpTheirs = computeFingerprint(userId: theirUserId, identityKey: theirIdentityKey)
let combined: Data
if myUserId < theirUserId {
combined = fpMine + fpTheirs
} else {
combined = fpTheirs + fpMine
}
// 64 bytes -> 12 groups of 5 digits
var groups: [String] = []
for i in 0..<12 {
let start = i * 5
let end = min(start + 5, combined.count)
let slice = combined[combined.startIndex + start ..< combined.startIndex + end]
let num = bigEndianUInt64(slice) % 100000
groups.append(String(format: "%05d", num))
}
return [
groups[0..<4].joined(separator: " "),
groups[4..<8].joined(separator: " "),
groups[8..<12].joined(separator: " "),
].joined(separator: "\n")
}
// MARK: - QR Code
/// Encode user identity for QR code verification.
///
/// Format: version(1B=0x01) + uid_len(1B) + uid(UTF-8) + identity_key(32B).
static func encodeVerificationQR(userId: String, identityKey: Data) -> Data {
let uidBytes = Data(userId.utf8)
var data = Data([0x01, UInt8(uidBytes.count)])
data.append(uidBytes)
data.append(identityKey)
return data
}
/// Decode QR code verification payload.
///
/// Returns (userId, identityKey).
/// Throws on invalid format.
static func decodeVerificationQR(_ data: Data) throws -> (userId: String, identityKey: Data) {
guard data.count >= 3 else {
throw VerificationError.qrDataTooShort
}
guard data[data.startIndex] == 0x01 else {
throw VerificationError.unknownQRVersion(data[data.startIndex])
}
let uidLen = Int(data[data.startIndex + 1])
guard data.count >= 2 + uidLen + 32 else {
throw VerificationError.qrDataTruncated
}
let uidData = data[data.startIndex + 2 ..< data.startIndex + 2 + uidLen]
guard let userId = String(data: uidData, encoding: .utf8) else {
throw VerificationError.invalidUTF8
}
let identityKey = Data(data[data.startIndex + 2 + uidLen ..< data.startIndex + 2 + uidLen + 32])
return (userId, identityKey)
}
// MARK: - Helpers
/// Convert up to 8 bytes to UInt64, big-endian.
private static func bigEndianUInt64(_ data: Data) -> UInt64 {
var result: UInt64 = 0
for byte in data {
result = result << 8 | UInt64(byte)
}
return result
}
}
// MARK: - UInt16 Big-Endian
private extension UInt16 {
var bigEndianData: Data {
var value = self.bigEndian
return Data(bytes: &value, count: 2)
}
}
// MARK: - Verification Errors
enum VerificationError: Error, LocalizedError {
case qrDataTooShort
case unknownQRVersion(UInt8)
case qrDataTruncated
case invalidUTF8
var errorDescription: String? {
switch self {
case .qrDataTooShort: return "QR data too short"
case .unknownQRVersion(let v): return "Unknown QR version: \(v)"
case .qrDataTruncated: return "QR data truncated"
case .invalidUTF8: return "Invalid UTF-8 in QR data"
}
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
enum CryptoError: Error, LocalizedError {
case invalidBase64
case invalidHex
case invalidKeyData(String)
case invalidSignature
case signatureVerificationFailed
case encryptionFailed(String)
case decryptionFailed(String)
case invalidECP1Format
case pbkdf2Failed
case rsaKeyGenerationFailed
case rsaOperationFailed(String)
case x3dhFailed(String)
case ratchetError(String)
case senderKeyError(String)
case maxSkipExceeded
case duplicateMessage
case invalidHeader(String)
case stateImportFailed(String)
case keyConversionFailed(String)
var errorDescription: String? {
switch self {
case .invalidBase64: return "Invalid base64 encoding"
case .invalidHex: return "Invalid hex encoding"
case .invalidKeyData(let msg): return "Invalid key data: \(msg)"
case .invalidSignature: return "Invalid signature format"
case .signatureVerificationFailed: return "Signature verification failed"
case .encryptionFailed(let msg): return "Encryption failed: \(msg)"
case .decryptionFailed(let msg): return "Decryption failed: \(msg)"
case .invalidECP1Format: return "Invalid ECP1 key format"
case .pbkdf2Failed: return "PBKDF2 key derivation failed"
case .rsaKeyGenerationFailed: return "RSA key generation failed"
case .rsaOperationFailed(let msg): return "RSA operation failed: \(msg)"
case .x3dhFailed(let msg): return "X3DH failed: \(msg)"
case .ratchetError(let msg): return "Ratchet error: \(msg)"
case .senderKeyError(let msg): return "Sender key error: \(msg)"
case .maxSkipExceeded: return "Maximum message skip exceeded"
case .duplicateMessage: return "Duplicate message detected"
case .invalidHeader(let msg): return "Invalid header: \(msg)"
case .stateImportFailed(let msg): return "State import failed: \(msg)"
case .keyConversionFailed(let msg): return "Key conversion failed: \(msg)"
}
}
}
enum NetworkError: Error, LocalizedError {
case notConnected
case connectionFailed(String)
case timeout
case serverError(String)
case protocolError(String)
case messageTooLarge
case invalidResponse(String)
case authenticationFailed(String)
case alreadyConnected
var errorDescription: String? {
switch self {
case .notConnected: return "Not connected to server"
case .connectionFailed(let msg): return "Connection failed: \(msg)"
case .timeout: return "Request timed out"
case .serverError(let msg): return "Server error: \(msg)"
case .protocolError(let msg): return "Protocol error: \(msg)"
case .messageTooLarge: return "Message exceeds maximum size"
case .invalidResponse(let msg): return "Invalid response: \(msg)"
case .authenticationFailed(let msg): return "Authentication failed: \(msg)"
case .alreadyConnected: return "Already connected"
}
}
}
enum ChatError: Error, LocalizedError {
case notLoggedIn
case conversationNotFound
case membershipRequired
case permissionDenied(String)
case operationFailed(String)
case fileError(String)
case invalidData(String)
var errorDescription: String? {
switch self {
case .notLoggedIn: return "Not logged in"
case .conversationNotFound: return "Conversation not found"
case .membershipRequired: return "Must be a member of this conversation"
case .permissionDenied(let msg): return "Permission denied: \(msg)"
case .operationFailed(let msg): return "Operation failed: \(msg)"
case .fileError(let msg): return "File error: \(msg)"
case .invalidData(let msg): return "Invalid data: \(msg)"
}
}
}

View File

@@ -0,0 +1,196 @@
import Foundation
import CryptoKit
/// Core cryptographic utilities: AES-GCM, HKDF, KDF helpers
enum CryptoUtils {
// MARK: - AES-256-GCM
/// Encrypt with AES-256-GCM. Returns (key, nonce, ciphertext, tag) all as Data.
/// If key is nil, generates a random 256-bit key.
/// Matches Python: aes_encrypt(plaintext, key=None)
static func aesEncrypt(_ plaintext: Data, key: Data? = nil) throws -> (key: Data, nonce: Data, ciphertext: Data, tag: Data) {
let keyData = key ?? Data.randomBytes(32)
let symmetricKey = SymmetricKey(data: keyData)
let nonceData = Data.randomBytes(12)
let gcmNonce = try AES.GCM.Nonce(data: nonceData)
let sealedBox = try AES.GCM.seal(plaintext, using: symmetricKey, nonce: gcmNonce)
return (
key: keyData,
nonce: nonceData,
ciphertext: Data(sealedBox.ciphertext),
tag: Data(sealedBox.tag)
)
}
/// Decrypt with AES-256-GCM.
/// Matches Python: aes_decrypt(key, nonce, ciphertext, tag)
static func aesDecrypt(key: Data, nonce: Data, ciphertext: Data, tag: Data) throws -> Data {
let symmetricKey = SymmetricKey(data: key)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
let sealedBox = try AES.GCM.SealedBox(
nonce: gcmNonce,
ciphertext: ciphertext,
tag: tag
)
do {
return try AES.GCM.open(sealedBox, using: symmetricKey)
} catch {
throw CryptoError.decryptionFailed("AES-GCM decryption failed")
}
}
/// Encrypt with AES-256-GCM using AAD. Returns ciphertext with tag appended.
/// Used by Double Ratchet and Sender Keys.
static func aesGcmEncrypt(_ plaintext: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
let symmetricKey = SymmetricKey(data: key)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
let sealedBox = try AES.GCM.seal(
plaintext,
using: symmetricKey,
nonce: gcmNonce,
authenticating: aad
)
// Return ciphertext + tag concatenated (matches Python AESGCM.encrypt)
return Data(sealedBox.ciphertext) + Data(sealedBox.tag)
}
/// Decrypt AES-256-GCM with AAD. Input ciphertext has tag appended (last 16 bytes).
static func aesGcmDecrypt(_ ctWithTag: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
guard ctWithTag.count >= 16 else {
throw CryptoError.decryptionFailed("Ciphertext too short")
}
let ct = ctWithTag.prefix(ctWithTag.count - 16)
let tag = ctWithTag.suffix(16)
let symmetricKey = SymmetricKey(data: key)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
let sealedBox = try AES.GCM.SealedBox(
nonce: gcmNonce,
ciphertext: ct,
tag: tag
)
do {
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: aad)
} catch {
throw CryptoError.decryptionFailed("AES-GCM decryption with AAD failed")
}
}
// MARK: - HKDF
/// HKDF-SHA256 key derivation.
/// Matches Python: hkdf_derive(input_key, salt, info, length=32)
static func hkdfDerive(inputKey: Data, salt: Data, info: Data, length: Int = 32) -> Data {
let symmetricKey = SymmetricKey(data: inputKey)
let derived = HKDF<SHA256>.deriveKey(
inputKeyMaterial: symmetricKey,
salt: salt,
info: info,
outputByteCount: length
)
return derived.withUnsafeBytes { Data($0) }
}
// MARK: - KDF for Double Ratchet
/// Root key KDF. Returns (newRootKey, chainKey).
/// HKDF with rootKey as salt and DH output as input. Derives 64 bytes, split in half.
/// Matches Python: kdf_rk(root_key, dh_output)
static func kdfRK(rootKey: Data, dhOutput: Data) -> (newRootKey: Data, chainKey: Data) {
let derived = hkdfDerive(
inputKey: dhOutput,
salt: rootKey,
info: Data(Constants.rootKeyInfo.utf8),
length: 64
)
return (derived.prefix(32), Data(derived.suffix(32)))
}
/// Chain key KDF. Returns (newChainKey, messageKey).
/// HMAC-SHA256: messageKey = HMAC(chainKey, 0x01), newChainKey = HMAC(chainKey, 0x02)
/// Matches Python: kdf_ck(chain_key)
static func kdfCK(chainKey: Data) -> (newChainKey: Data, messageKey: Data) {
let symmetricKey = SymmetricKey(data: chainKey)
let messageKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x01]), using: symmetricKey))
let newChainKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x02]), using: symmetricKey))
return (newChainKey, messageKey)
}
// MARK: - Self-Encryption Key
/// Derive static AES-256 key from identity key for self-encrypted message copies.
/// Matches Python: derive_self_encryption_key(identity_private)
static func deriveSelfEncryptionKey(identityPrivateRaw: Data) -> Data {
hkdfDerive(
inputKey: identityPrivateRaw,
salt: Data(Constants.selfEncryptionSalt.utf8),
info: Data(Constants.selfEncryptionInfo.utf8),
length: 32
)
}
// MARK: - Local Storage Key
/// Derive AES-256 key for encrypting local session/sender key files.
/// Matches Python: derive_local_storage_key(identity_private)
static func deriveLocalStorageKey(identityPrivateRaw: Data) -> Data {
hkdfDerive(
inputKey: identityPrivateRaw,
salt: Data(Constants.localStorageSalt.utf8),
info: Data(Constants.localStorageInfo.utf8),
length: 32
)
}
// MARK: - Local File Encryption
/// Encrypt data for local storage. Format: nonce(12) + tag(16) + ciphertext
/// Matches Python: _encrypt_local(data, key)
static func encryptLocal(_ data: Data, key: Data) throws -> Data {
let symmetricKey = SymmetricKey(data: key)
let sealedBox = try AES.GCM.seal(data, using: symmetricKey)
var result = Data()
result.append(Data(sealedBox.nonce)) // 12 bytes
result.append(Data(sealedBox.tag)) // 16 bytes
result.append(Data(sealedBox.ciphertext)) // N bytes
return result
}
/// Decrypt locally stored data. Format: nonce(12) + tag(16) + ciphertext
/// Matches Python: _decrypt_local(raw, key)
static func decryptLocal(_ raw: Data, key: Data) throws -> Data {
guard raw.count >= 28 else { // 12 + 16 minimum
throw CryptoError.decryptionFailed("Local encrypted data too short")
}
let nonce = raw[0..<12]
let tag = raw[12..<28]
let ct = raw[28...]
let symmetricKey = SymmetricKey(data: key)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
let sealedBox = try AES.GCM.SealedBox(
nonce: gcmNonce,
ciphertext: ct,
tag: tag
)
do {
return try AES.GCM.open(sealedBox, using: symmetricKey)
} catch {
throw CryptoError.decryptionFailed("Local storage decryption failed")
}
}
}

View File

@@ -0,0 +1,393 @@
import Foundation
import CryptoKit
/// Ratchet header sent with each message
struct RatchetHeader {
let dhPub: Data // sender's current ratchet public key (32 bytes)
let n: Int // message number in current sending chain
let pn: Int // number of messages in previous sending chain
/// Serialize header to JSON bytes for use as AAD.
/// Matches Python: RatchetHeader.serialize()
/// IMPORTANT: Must match Python's json.dumps() format exactly (with spaces after : and ,)
func serialize() -> Data {
// Python json.dumps produces: {"dh_pub": "...", "n": 0, "pn": 0}
// Note the spaces after colons and commas - this is critical for AAD matching
let jsonString = "{\"dh_pub\": \"\(dhPub.hexString)\", \"n\": \(n), \"pn\": \(pn)}"
return jsonString.data(using: .utf8)!
}
/// Convert to dictionary for protocol.
/// Matches Python: RatchetHeader.to_dict()
func toDict() -> [String: Any] {
[
"dh_pub": dhPub.hexString,
"n": n,
"pn": pn,
]
}
/// Parse from dictionary.
/// Matches Python: RatchetHeader.from_dict(d)
static func fromDict(_ d: [String: Any]) throws -> RatchetHeader {
guard let dhPubHex = d["dh_pub"] as? String,
let dhPub = Data(hexString: dhPubHex),
let n = d["n"] as? Int,
let pn = d["pn"] as? Int else {
throw CryptoError.invalidHeader("Missing or invalid header fields")
}
return RatchetHeader(dhPub: dhPub, n: n, pn: pn)
}
}
/// Signal Double Ratchet implementation.
/// Matches Python: DoubleRatchet class in crypto_utils.py
class DoubleRatchet {
private(set) var dhPair: (privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey)?
private(set) var dhRemote: Curve25519.KeyAgreement.PublicKey?
private(set) var rootKey: Data = Data()
private(set) var sendChainKey: Data?
private(set) var recvChainKey: Data?
private(set) var sendN: Int = 0
private(set) var recvN: Int = 0
private(set) var prevSendN: Int = 0
// Skipped message keys: "dh_pub_hex:n" message_key
private(set) var skipped: [String: Data] = [:]
/// Attached X3DH header set when creating a new session, consumed on first send.
/// Matches Python: ratchet._x3dh_header
var x3dhHeader: [String: Any]?
init() {}
// MARK: - Initialization
/// Initialize as initiator (Alice) after X3DH.
/// Matches Python: DoubleRatchet.init_alice(shared_secret, bob_spk_pub)
static func initAlice(sharedSecret: Data, bobSpkPub: Curve25519.KeyAgreement.PublicKey) throws -> DoubleRatchet {
let ratchet = DoubleRatchet()
let (priv, pub) = X25519Crypto.generateKeypair()
ratchet.dhPair = (priv, pub)
ratchet.dhRemote = bobSpkPub
// Debug: print ratchet inputs (matching Python _dh_ratchet)
#if DEBUG
print("DEBUG initAlice: shared_secret (root_key) = \(sharedSecret.hexString)")
print("DEBUG initAlice: my_dh_pub = \(X25519Crypto.serializePublic(pub).hexString)")
print("DEBUG initAlice: remote_dh_pub (bob_spk) = \(X25519Crypto.serializePublic(bobSpkPub).hexString)")
#endif
// Perform DH ratchet to derive send chain
let dhOutput = try X25519Crypto.dh(priv, bobSpkPub)
let (newRK, sendCK) = CryptoUtils.kdfRK(rootKey: sharedSecret, dhOutput: dhOutput)
#if DEBUG
print("DEBUG initAlice: dh_output = \(dhOutput.hexString)")
print("DEBUG initAlice: new_root_key = \(newRK.hexString)")
print("DEBUG initAlice: send_chain_key = \(sendCK.hexString)")
#endif
ratchet.rootKey = newRK
ratchet.sendChainKey = sendCK
ratchet.recvChainKey = nil
ratchet.sendN = 0
ratchet.recvN = 0
ratchet.prevSendN = 0
return ratchet
}
/// Initialize as responder (Bob) after X3DH.
/// Matches Python: DoubleRatchet.init_bob(shared_secret, spk_pair)
static func initBob(
sharedSecret: Data,
spkPair: (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey)
) -> DoubleRatchet {
let ratchet = DoubleRatchet()
ratchet.dhPair = spkPair
ratchet.rootKey = sharedSecret
ratchet.sendChainKey = nil
ratchet.recvChainKey = nil
ratchet.sendN = 0
ratchet.recvN = 0
ratchet.prevSendN = 0
return ratchet
}
// MARK: - Encrypt
/// Encrypt a message.
/// Returns (header dict, ciphertext with tag, nonce).
/// Matches Python: DoubleRatchet.encrypt(plaintext)
func encrypt(_ plaintext: Data) throws -> (header: [String: Any], ciphertext: Data, nonce: Data) {
guard sendChainKey != nil else {
throw CryptoError.ratchetError("Send chain not initialized")
}
guard let dhPair = dhPair else {
throw CryptoError.ratchetError("DH pair not set")
}
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: sendChainKey!)
sendChainKey = newCK
let header = RatchetHeader(
dhPub: X25519Crypto.serializePublic(dhPair.publicKey),
n: sendN,
pn: prevSendN
)
let nonce = Data.randomBytes(12)
let aad = header.serialize()
// Debug: print encrypt values (matching Python decrypt)
#if DEBUG
print("DEBUG encrypt: message_key = \(messageKey.hexString)")
print("DEBUG encrypt: aad = \(aad.hexString)")
print("DEBUG encrypt: aad_str = \(String(data: aad, encoding: .utf8) ?? "nil")")
print("DEBUG encrypt: nonce = \(nonce.hexString)")
#endif
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
#if DEBUG
print("DEBUG encrypt: ciphertext_len = \(ctWithTag.count)")
#endif
sendN += 1
return (header.toDict(), ctWithTag, nonce)
}
// MARK: - Decrypt
/// Decrypt a message. Handles DH ratchet step if new dh_pub.
/// State is snapshotted before modification and restored on failure (M9 fix).
/// Matches Python: DoubleRatchet.decrypt(header_dict, ciphertext, nonce)
func decrypt(headerDict: [String: Any], ciphertext: Data, nonce: Data) throws -> Data {
let header = try RatchetHeader.fromDict(headerDict)
let remoteDhPubBytes = header.dhPub
// Check if this is from a skipped message
let skipKey = "\(remoteDhPubBytes.hexString):\(header.n)"
if let mk = skipped[skipKey] {
skipped.removeValue(forKey: skipKey)
let aad = header.serialize()
do {
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
} catch {
// Restore skipped key on failure
skipped[skipKey] = mk
throw error
}
}
// Snapshot state before modifications
let snap = snapshot()
do {
let remoteDhPub = try X25519Crypto.loadPublic(remoteDhPubBytes)
let currentRemoteBytes: Data? = dhRemote.map { X25519Crypto.serializePublic($0) }
if currentRemoteBytes == nil || remoteDhPubBytes != currentRemoteBytes {
// New DH ratchet step
try skipMessages(until: header.pn)
try dhRatchet(remoteDhPub: remoteDhPub)
}
try skipMessages(until: header.n)
// Derive message key from receive chain
guard recvChainKey != nil else {
throw CryptoError.ratchetError("Receive chain key is nil")
}
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
recvChainKey = newCK
recvN += 1
let aad = header.serialize()
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
} catch {
restore(snap)
throw error
}
}
// MARK: - State Snapshot/Restore (M9)
private struct Snapshot {
let dhPairPriv: Data?
let dhPairPub: Data?
let dhRemote: Data?
let rootKey: Data
let sendChainKey: Data?
let recvChainKey: Data?
let sendN: Int
let recvN: Int
let prevSendN: Int
let skipped: [String: Data]
}
private func snapshot() -> Snapshot {
Snapshot(
dhPairPriv: dhPair.map { X25519Crypto.serializePrivate($0.privateKey) },
dhPairPub: dhPair.map { X25519Crypto.serializePublic($0.publicKey) },
dhRemote: dhRemote.map { X25519Crypto.serializePublic($0) },
rootKey: rootKey,
sendChainKey: sendChainKey,
recvChainKey: recvChainKey,
sendN: sendN,
recvN: recvN,
prevSendN: prevSendN,
skipped: skipped
)
}
private func restore(_ snap: Snapshot) {
if let privData = snap.dhPairPriv, let pubData = snap.dhPairPub,
let priv = try? X25519Crypto.loadPrivate(privData),
let pub = try? X25519Crypto.loadPublic(pubData) {
dhPair = (priv, pub)
} else {
dhPair = nil
}
if let remoteData = snap.dhRemote, let remote = try? X25519Crypto.loadPublic(remoteData) {
dhRemote = remote
} else {
dhRemote = nil
}
rootKey = snap.rootKey
sendChainKey = snap.sendChainKey
recvChainKey = snap.recvChainKey
sendN = snap.sendN
recvN = snap.recvN
prevSendN = snap.prevSendN
skipped = snap.skipped
}
// MARK: - Internal Ratchet Operations
private func skipMessages(until: Int) throws {
guard recvChainKey != nil else { return }
if until - recvN > Constants.maxSkip {
throw CryptoError.maxSkipExceeded
}
while recvN < until {
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
recvChainKey = newCK
let remoteHex = dhRemote.map { X25519Crypto.serializePublic($0).hexString } ?? ""
skipped["\(remoteHex):\(recvN)"] = mk
recvN += 1
}
}
private func dhRatchet(remoteDhPub: Curve25519.KeyAgreement.PublicKey) throws {
prevSendN = sendN
sendN = 0
recvN = 0
dhRemote = remoteDhPub
// Derive new receive chain key
guard let dhPair = dhPair else {
throw CryptoError.ratchetError("DH pair not set")
}
let dhOutput1 = try X25519Crypto.dh(dhPair.privateKey, remoteDhPub)
let (newRK1, recvCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput1)
rootKey = newRK1
recvChainKey = recvCK
// Generate new DH pair and derive new send chain key
let (newPriv, newPub) = X25519Crypto.generateKeypair()
self.dhPair = (newPriv, newPub)
let dhOutput2 = try X25519Crypto.dh(newPriv, remoteDhPub)
let (newRK2, sendCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput2)
rootKey = newRK2
sendChainKey = sendCK
}
// MARK: - State Export/Import
/// Serialize full ratchet state for persistent storage.
/// Produces JSON matching Python's DoubleRatchet.export_state() exactly.
func exportState() throws -> Data {
var state: [String: Any] = [:]
if let pair = dhPair {
state["dh_priv"] = X25519Crypto.serializePrivate(pair.privateKey).hexString
state["dh_pub"] = X25519Crypto.serializePublic(pair.publicKey).hexString
} else {
state["dh_priv"] = NSNull()
state["dh_pub"] = NSNull()
}
if let remote = dhRemote {
state["dh_remote"] = X25519Crypto.serializePublic(remote).hexString
} else {
state["dh_remote"] = NSNull()
}
state["root_key"] = rootKey.hexString
state["send_ck"] = sendChainKey?.hexString ?? NSNull()
state["recv_ck"] = recvChainKey?.hexString ?? NSNull()
state["send_n"] = sendN
state["recv_n"] = recvN
state["prev_send_n"] = prevSendN
// Skipped keys: Python format is "dh_pub_hex:n" -> message_key_hex
var skippedDict: [String: String] = [:]
for (key, value) in skipped {
skippedDict[key] = value.hexString
}
state["skipped"] = skippedDict
return try JSONSerialization.data(withJSONObject: state)
}
/// Deserialize ratchet state.
/// Matches Python: DoubleRatchet.import_state(data)
static func importState(_ data: Data) throws -> DoubleRatchet {
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw CryptoError.stateImportFailed("Invalid JSON")
}
let r = DoubleRatchet()
if let dhPrivHex = state["dh_priv"] as? String,
let dhPubHex = state["dh_pub"] as? String,
let privData = Data(hexString: dhPrivHex),
let pubData = Data(hexString: dhPubHex) {
let priv = try X25519Crypto.loadPrivate(privData)
let pub = try X25519Crypto.loadPublic(pubData)
r.dhPair = (priv, pub)
}
if let dhRemoteHex = state["dh_remote"] as? String,
let remoteData = Data(hexString: dhRemoteHex) {
r.dhRemote = try X25519Crypto.loadPublic(remoteData)
}
guard let rootKeyHex = state["root_key"] as? String,
let rootKey = Data(hexString: rootKeyHex) else {
throw CryptoError.stateImportFailed("Missing root_key")
}
r.rootKey = rootKey
if let sendCKHex = state["send_ck"] as? String, let ck = Data(hexString: sendCKHex) {
r.sendChainKey = ck
}
if let recvCKHex = state["recv_ck"] as? String, let ck = Data(hexString: recvCKHex) {
r.recvChainKey = ck
}
r.sendN = state["send_n"] as? Int ?? 0
r.recvN = state["recv_n"] as? Int ?? 0
r.prevSendN = state["prev_send_n"] as? Int ?? 0
if let skippedDict = state["skipped"] as? [String: String] {
for (key, valueHex) in skippedDict {
if let value = Data(hexString: valueHex) {
r.skipped[key] = value
}
}
}
return r
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
import CryptoKit
/// Ed25519 signing operations Identity Key management
enum Ed25519Crypto {
// MARK: - Key Generation
/// Generate Ed25519 keypair
static func generateKeypair() -> (privateKey: Curve25519.Signing.PrivateKey, publicKey: Curve25519.Signing.PublicKey) {
let privateKey = Curve25519.Signing.PrivateKey()
return (privateKey, privateKey.publicKey)
}
// MARK: - Serialization
/// Serialize Ed25519 private key. With password: raw 32B ECP1. Without: raw 32B.
/// Matches Python: serialize_ed25519_private(key, password=None)
static func serializePrivate(_ key: Curve25519.Signing.PrivateKey, password: Data? = nil) throws -> Data {
let raw = key.rawData // 32 bytes
if let password = password {
return try KeyEncryption.encrypt(raw, password: password)
}
return raw
}
/// Serialize Ed25519 public key to 32 raw bytes.
/// Matches Python: serialize_ed25519_public(key)
static func serializePublic(_ key: Curve25519.Signing.PublicKey) -> Data {
key.rawData // 32 bytes
}
// MARK: - Loading
/// Load Ed25519 private key. Auto-detects ECP1 / raw 32B.
/// Matches Python: load_ed25519_private(data, password=None)
static func loadPrivate(_ data: Data, password: Data? = nil) throws -> Curve25519.Signing.PrivateKey {
if KeyEncryption.isECP1Format(data) {
guard let pwd = password else {
throw CryptoError.invalidKeyData("ECP1 key requires password")
}
let raw = try KeyEncryption.decrypt(data, password: pwd)
return try Curve25519.Signing.PrivateKey(rawRepresentation: raw)
}
if data.count == 32 {
return try Curve25519.Signing.PrivateKey(rawRepresentation: data)
}
throw CryptoError.invalidKeyData("Cannot parse Ed25519 private key (\(data.count) bytes)")
}
/// Load Ed25519 public key from 32 raw bytes.
/// Matches Python: load_ed25519_public(data)
static func loadPublic(_ data: Data) throws -> Curve25519.Signing.PublicKey {
guard data.count == 32 else {
throw CryptoError.invalidKeyData("Ed25519 public key must be 32 bytes, got \(data.count)")
}
return try Curve25519.Signing.PublicKey(rawRepresentation: data)
}
// MARK: - Sign / Verify
/// Sign data with Ed25519. Returns 64-byte signature.
/// Matches Python: ed25519_sign(private_key, data)
static func sign(_ privateKey: Curve25519.Signing.PrivateKey, data: Data) throws -> Data {
Data(try privateKey.signature(for: data))
}
/// Verify Ed25519 signature.
/// Matches Python: ed25519_verify(public_key, signature, data)
static func verify(_ publicKey: Curve25519.Signing.PublicKey, signature: Data, data: Data) -> Bool {
publicKey.isValidSignature(signature, for: data)
}
}

View File

@@ -0,0 +1,231 @@
import Foundation
/// Pure Swift GF(2^255-19) arithmetic for Ed25519 X25519 public key conversion.
///
/// The conversion formula is: u = (1 + y) / (1 - y) mod p
/// where p = 2^255 - 19, and y is the Ed25519 public key's y-coordinate.
///
/// Uses 4-limb UInt64 representation (little-endian).
enum FieldArithmetic {
// p = 2^255 - 19
static let p: [UInt64] = [
0xFFFF_FFFF_FFFF_FFED, // limb 0 (least significant)
0xFFFF_FFFF_FFFF_FFFF, // limb 1
0xFFFF_FFFF_FFFF_FFFF, // limb 2
0x7FFF_FFFF_FFFF_FFFF, // limb 3 (most significant, 2^63 - 1 accounting for -19)
]
/// Load a 256-bit little-endian byte array into 4 UInt64 limbs
static func load(_ bytes: Data) -> [UInt64] {
precondition(bytes.count == 32)
var limbs = [UInt64](repeating: 0, count: 4)
for i in 0..<4 {
var val: UInt64 = 0
for j in 0..<8 {
val |= UInt64(bytes[i * 8 + j]) << (j * 8)
}
limbs[i] = val
}
return limbs
}
/// Store 4 UInt64 limbs as 32 little-endian bytes
static func store(_ limbs: [UInt64]) -> Data {
var bytes = Data(count: 32)
for i in 0..<4 {
for j in 0..<8 {
bytes[i * 8 + j] = UInt8((limbs[i] >> (j * 8)) & 0xFF)
}
}
return bytes
}
/// a + b mod p
static func add(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
var result = [UInt64](repeating: 0, count: 4)
var carry: UInt64 = 0
for i in 0..<4 {
let (sum1, c1) = a[i].addingReportingOverflow(b[i])
let (sum2, c2) = sum1.addingReportingOverflow(carry)
result[i] = sum2
carry = (c1 ? 1 : 0) + (c2 ? 1 : 0)
}
// Reduce mod p
return reduceOnce(result, carry: carry)
}
/// a - b mod p
static func sub(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
var result = [UInt64](repeating: 0, count: 4)
var borrow: UInt64 = 0
for i in 0..<4 {
let (diff1, b1) = a[i].subtractingReportingOverflow(b[i])
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
result[i] = diff2
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
}
if borrow > 0 {
// Add p back
var c: UInt64 = 0
for i in 0..<4 {
let (s1, c1) = result[i].addingReportingOverflow(p[i])
let (s2, c2) = s1.addingReportingOverflow(c)
result[i] = s2
c = (c1 ? 1 : 0) + (c2 ? 1 : 0)
}
}
return result
}
/// Multiply two 256-bit numbers mod p using schoolbook multiplication
static func mul(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
// Full 512-bit product in 8 limbs
var product = [UInt64](repeating: 0, count: 8)
for i in 0..<4 {
var carry: UInt64 = 0
for j in 0..<4 {
let (hi, lo) = a[i].multipliedFullWidth(by: b[j])
let (sum1, c1) = product[i + j].addingReportingOverflow(lo)
let (sum2, c2) = sum1.addingReportingOverflow(carry)
product[i + j] = sum2
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
}
product[i + 4] = carry
}
// Reduce mod p using Barrett-like reduction
// Since p = 2^255 - 19, for a 512-bit number we can use:
// x mod p = (x_low + x_high * 2^256) mod p
// Since 2^255 19 (mod p), 2^256 38 (mod p)
return reduceFull(product)
}
/// Reduce 512-bit product mod p using 2^256 38 (mod p)
private static func reduceFull(_ product: [UInt64]) -> [UInt64] {
// Split: low = product[0..3], high = product[4..7]
// result = low + high * 38
var result = [UInt64](repeating: 0, count: 5)
// Start with low part
for i in 0..<4 {
result[i] = product[i]
}
// Add high * 38
var carry: UInt64 = 0
for i in 0..<4 {
let (hi, lo) = product[i + 4].multipliedFullWidth(by: 38)
let (sum1, c1) = result[i].addingReportingOverflow(lo)
let (sum2, c2) = sum1.addingReportingOverflow(carry)
result[i] = sum2
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
}
result[4] = carry
// The result might still be >= p, so reduce once more
// result[4] * 2^256 result[4] * 38 (mod p)
let extra: UInt64 = result[4]
result[4] = 0
if extra > 0 {
let (hi, lo) = extra.multipliedFullWidth(by: 38)
let (sum1, c1) = result[0].addingReportingOverflow(lo)
result[0] = sum1
var c = hi + (c1 ? 1 : 0)
for i in 1..<4 {
let (s, cf) = result[i].addingReportingOverflow(c)
result[i] = s
c = cf ? 1 : 0
}
// One more round if carry
if c > 0 {
let (s, _) = result[0].addingReportingOverflow(c * 38)
result[0] = s
}
}
var out = Array(result[0..<4])
// Final reduction: if >= p, subtract p
out = reduceOnce(out, carry: 0)
return out
}
/// If the number >= p, subtract p
private static func reduceOnce(_ val: [UInt64], carry: UInt64) -> [UInt64] {
if carry > 0 || isGreaterOrEqual(val, p) {
var result = [UInt64](repeating: 0, count: 4)
var borrow: UInt64 = 0
for i in 0..<4 {
let (diff1, b1) = val[i].subtractingReportingOverflow(p[i])
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
result[i] = diff2
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
}
// If borrow after subtracting p, the original was fine (shouldn't happen with carry)
if borrow > 0 && carry == 0 {
return val
}
return result
}
return val
}
/// Compare a >= b
private static func isGreaterOrEqual(_ a: [UInt64], _ b: [UInt64]) -> Bool {
for i in stride(from: 3, through: 0, by: -1) {
if a[i] > b[i] { return true }
if a[i] < b[i] { return false }
}
return true // equal
}
/// Modular inverse using Fermat's little theorem: a^(-1) = a^(p-2) mod p
static func inverse(_ a: [UInt64]) -> [UInt64] {
// p - 2 = 2^255 - 21
let pMinus2 = sub(p, [2, 0, 0, 0])
return power(a, pMinus2)
}
/// Modular exponentiation using square-and-multiply
static func power(_ base: [UInt64], _ exp: [UInt64]) -> [UInt64] {
var result: [UInt64] = [1, 0, 0, 0] // 1
var b = base
for i in 0..<4 {
var limb = exp[i]
let bits = (i == 3) ? 63 : 64 // top limb has 63 bits for p-2
for _ in 0..<bits {
if limb & 1 == 1 {
result = mul(result, b)
}
b = mul(b, b)
limb >>= 1
}
}
return result
}
// MARK: - Ed25519 X25519 Public Key Conversion
/// Convert Ed25519 public key (32 bytes) to X25519 public key (32 bytes).
/// Formula: u = (1 + y) * inverse(1 - y) mod p
static func ed25519PublicToX25519(_ ed25519Pub: Data) -> Data {
precondition(ed25519Pub.count == 32)
// Ed25519 public key is the y-coordinate with sign bit in the top bit of byte 31
var keyBytes = ed25519Pub
// Clear the sign bit
keyBytes[31] &= 0x7F
let y = load(keyBytes)
let one: [UInt64] = [1, 0, 0, 0]
let onePlusY = add(one, y)
let oneMinusY = sub(one, y)
let inv = inverse(oneMinusY)
let u = mul(onePlusY, inv)
return store(u)
}
}

View File

@@ -0,0 +1,106 @@
import Foundation
import CryptoKit
import CommonCrypto
/// ECP1 key encryption format: PBKDF2-HMAC-SHA256 (600k iterations) + AES-256-GCM
/// Wire format: magic(4) + salt(16) + nonce(12) + ciphertext_with_tag(N+16)
enum KeyEncryption {
/// Encrypt raw key bytes with password using ECP1 format
static func encrypt(_ rawBytes: Data, password: Data) throws -> Data {
let salt = Data.randomBytes(16)
let derivedKey = try pbkdf2(password: password, salt: salt)
let nonce = Data.randomBytes(12)
let symmetricKey = SymmetricKey(data: derivedKey)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
// AAD = ECP1 magic bytes (matching Python)
let sealedBox = try AES.GCM.seal(
rawBytes,
using: symmetricKey,
nonce: gcmNonce,
authenticating: Constants.ecp1Magic
)
// ciphertext + tag concatenated (matches Python's AESGCM.encrypt output)
var result = Data()
result.append(Constants.ecp1Magic) // 4 bytes
result.append(salt) // 16 bytes
result.append(nonce) // 12 bytes
result.append(sealedBox.ciphertext) // N bytes
result.append(sealedBox.tag) // 16 bytes
return result
}
/// Decrypt ECP1-encrypted key bytes with password
static func decrypt(_ data: Data, password: Data) throws -> Data {
guard data.count >= 48 else { // 4 + 16 + 12 + 16 minimum
throw CryptoError.invalidECP1Format
}
guard data.prefix(4) == Constants.ecp1Magic else {
throw CryptoError.invalidECP1Format
}
let salt = data[4..<20]
let nonce = data[20..<32]
let ctWithTag = data[32...]
guard ctWithTag.count >= 16 else {
throw CryptoError.invalidECP1Format
}
let derivedKey = try pbkdf2(password: password, salt: Data(salt))
let symmetricKey = SymmetricKey(data: derivedKey)
let gcmNonce = try AES.GCM.Nonce(data: nonce)
// Split ciphertext and tag
let ct = ctWithTag.prefix(ctWithTag.count - 16)
let tag = ctWithTag.suffix(16)
let sealedBox = try AES.GCM.SealedBox(
nonce: gcmNonce,
ciphertext: ct,
tag: tag
)
do {
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: Constants.ecp1Magic)
} catch {
throw CryptoError.decryptionFailed("ECP1 decryption failed - wrong password?")
}
}
/// Check if data starts with ECP1 magic
static func isECP1Format(_ data: Data) -> Bool {
data.count >= 4 && data.prefix(4) == Constants.ecp1Magic
}
// MARK: - PBKDF2
/// Derive 32-byte key using PBKDF2-HMAC-SHA256 with 600k iterations
static func pbkdf2(password: Data, salt: Data) throws -> Data {
var derivedKey = Data(count: 32)
let status = derivedKey.withUnsafeMutableBytes { derivedKeyPtr in
password.withUnsafeBytes { passwordPtr in
salt.withUnsafeBytes { saltPtr in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordPtr.baseAddress?.assumingMemoryBound(to: Int8.self),
password.count,
saltPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
Constants.pbkdf2Iterations,
derivedKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
32
)
}
}
}
guard status == kCCSuccess else {
throw CryptoError.pbkdf2Failed
}
return derivedKey
}
}

View File

@@ -0,0 +1,59 @@
import Foundation
/// Message padding for metadata privacy hides plaintext length.
/// Matches Python: crypto_utils.py pad_plaintext / unpad_plaintext
enum MessagePadding {
/// Magic byte prefix to distinguish padded from legacy unpadded messages.
private static let padMagic: UInt8 = 0x01
/// Bucket sizes for length hiding (64B to 64KB).
private static let padBuckets = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]
/// Pad plaintext to nearest bucket size to hide message length.
///
/// Format: `0x01 + plaintext + random_padding + pad_length(4B big-endian)`
/// Prefix 0x01 distinguishes padded messages from legacy unpadded (which start with '{').
static func pad(_ plaintext: Data) -> Data {
var content = Data([padMagic])
content.append(plaintext)
// +4 for the length suffix
let minSize = content.count + 4
let target = padBuckets.first(where: { $0 >= minSize }) ?? minSize
let padLen = target - content.count
// random_padding (padLen - 4 bytes) + pad_length (4 bytes big-endian)
var result = content
result.append(Data.randomBytes(padLen - 4))
result.append(UInt32(padLen).bigEndianData)
return result
}
/// Remove padding. Returns raw plaintext for both padded and legacy unpadded messages.
static func unpad(_ data: Data) -> Data {
guard !data.isEmpty else { return data }
// Legacy unpadded message (starts with '{' for JSON)
guard data[data.startIndex] == padMagic else { return data }
// Too short to be validly padded (magic + at least 4 bytes for length)
guard data.count >= 5 else { return data }
// Read pad_length from last 4 bytes (big-endian UInt32)
let padLenOffset = data.count - 4
let padLen = data.withUnsafeBytes { ptr -> UInt32 in
var value: UInt32 = 0
withUnsafeMutableBytes(of: &value) { dest in
dest.copyBytes(from: UnsafeRawBufferPointer(rebasing: ptr[padLenOffset...]))
}
return UInt32(bigEndian: value)
}
// Validate padding metadata
guard padLen >= 4, padLen <= data.count - 1 else { return data }
// Strip: skip magic byte (index 0), take up to (data.count - padLen)
return data[data.startIndex + 1 ..< data.startIndex + data.count - Int(padLen)]
}
}

View File

@@ -0,0 +1,356 @@
import Foundation
import Security
/// RSA-4096 operations used for login challenge-response ONLY
enum RSACrypto {
// MARK: - Key Generation
/// Generate RSA-4096 keypair
static func generateKeypair() throws -> (privateKey: SecKey, publicKey: SecKey) {
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits as String: 4096,
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw CryptoError.rsaKeyGenerationFailed
}
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
throw CryptoError.rsaKeyGenerationFailed
}
return (privateKey, publicKey)
}
// MARK: - Serialization
/// Serialize RSA private key. With password: DER ECP1. Without: PEM PKCS#8.
static func serializePrivateKey(_ key: SecKey, password: Data? = nil) throws -> Data {
var error: Unmanaged<CFError>?
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
throw CryptoError.rsaOperationFailed("Failed to export private key")
}
// SecKey exports in PKCS#1 format on iOS wrap in PKCS#8 for Python compat
let pkcs8 = wrapRSAPrivateKeyPKCS8(derData)
if let password = password {
return try KeyEncryption.encrypt(pkcs8, password: password)
}
// PEM encode for Python compatibility
return pemEncode(pkcs8, label: "PRIVATE KEY")
}
/// Serialize RSA public key as PEM SubjectPublicKeyInfo (Python-compatible)
static func serializePublicKey(_ key: SecKey) throws -> Data {
var error: Unmanaged<CFError>?
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
throw CryptoError.rsaOperationFailed("Failed to export public key")
}
// SecKey exports PKCS#1 on iOS wrap in SubjectPublicKeyInfo
let spki = wrapRSAPublicKeySPKI(derData)
return pemEncode(spki, label: "PUBLIC KEY")
}
/// Load RSA private key. Auto-detects ECP1 vs PEM format.
static func loadPrivateKey(_ data: Data, password: Data? = nil) throws -> SecKey {
let derData: Data
if KeyEncryption.isECP1Format(data) {
guard let pwd = password else {
throw CryptoError.invalidKeyData("ECP1 key requires password")
}
let raw = try KeyEncryption.decrypt(data, password: pwd)
derData = unwrapPKCS8ToRSAPrivateKey(raw)
} else {
// PEM format
let pem = String(data: data, encoding: .utf8) ?? ""
derData = try pemDecode(pem, label: "PRIVATE KEY")
.flatMap { unwrapPKCS8ToRSAPrivateKey($0) }
?? pemDecode(pem, label: "RSA PRIVATE KEY")
?? { throw CryptoError.invalidKeyData("Cannot parse RSA private key PEM") }()
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
throw CryptoError.invalidKeyData("Failed to create RSA private key from DER")
}
return key
}
/// Load RSA public key from PEM
static func loadPublicKey(_ pemData: Data) throws -> SecKey {
let pem = String(data: pemData, encoding: .utf8) ?? ""
// Try SubjectPublicKeyInfo (PUBLIC KEY), unwrap to PKCS#1
let derData: Data
if let spki = pemDecode(pem, label: "PUBLIC KEY") {
derData = unwrapSPKIToRSAPublicKey(spki)
} else if let pkcs1 = pemDecode(pem, label: "RSA PUBLIC KEY") {
derData = pkcs1
} else {
throw CryptoError.invalidKeyData("Cannot parse RSA public key PEM")
}
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
]
var error: Unmanaged<CFError>?
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
throw CryptoError.invalidKeyData("Failed to create RSA public key from DER")
}
return key
}
// MARK: - Sign / Verify
/// Sign data with RSA-PSS SHA-256.
/// Note: iOS uses salt_length = hash_length (32). Server must use PSS.AUTO to verify.
static func sign(_ privateKey: SecKey, data: Data) throws -> Data {
var error: Unmanaged<CFError>?
guard let signature = SecKeyCreateSignature(
privateKey,
.rsaSignatureMessagePSSSHA256,
data as CFData,
&error
) as Data? else {
throw CryptoError.rsaOperationFailed("RSA signing failed")
}
return signature
}
/// Verify RSA-PSS SHA-256 signature
static func verify(_ publicKey: SecKey, signature: Data, data: Data) -> Bool {
SecKeyVerifySignature(
publicKey,
.rsaSignatureMessagePSSSHA256,
data as CFData,
signature as CFData,
nil
)
}
// MARK: - RSA-OAEP Encrypt / Decrypt (for device pairing)
/// Encrypt data with RSA-OAEP SHA-256 using a public key
static func encrypt(_ publicKey: SecKey, plaintext: Data) throws -> Data {
var error: Unmanaged<CFError>?
guard let encrypted = SecKeyCreateEncryptedData(
publicKey,
.rsaEncryptionOAEPSHA256,
plaintext as CFData,
&error
) as Data? else {
throw CryptoError.rsaOperationFailed("RSA-OAEP encryption failed")
}
return encrypted
}
/// Decrypt data with RSA-OAEP SHA-256 using a private key
static func decrypt(_ privateKey: SecKey, ciphertext: Data) throws -> Data {
var error: Unmanaged<CFError>?
guard let decrypted = SecKeyCreateDecryptedData(
privateKey,
.rsaEncryptionOAEPSHA256,
ciphertext as CFData,
&error
) as Data? else {
throw CryptoError.rsaOperationFailed("RSA-OAEP decryption failed")
}
return decrypted
}
/// Generate RSA-2048 keypair (for pairing temp keys smaller for OAEP payload)
static func generateKeypair2048() throws -> (privateKey: SecKey, publicKey: SecKey) {
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits as String: 2048,
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw CryptoError.rsaKeyGenerationFailed
}
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
throw CryptoError.rsaKeyGenerationFailed
}
return (privateKey, publicKey)
}
// MARK: - PEM Helpers
private static func pemEncode(_ der: Data, label: String) -> Data {
let base64 = der.base64EncodedString(options: .lineLength64Characters)
let pem = "-----BEGIN \(label)-----\n\(base64)\n-----END \(label)-----\n"
return Data(pem.utf8)
}
private static func pemDecode(_ pem: String, label: String) -> Data? {
let beginMarker = "-----BEGIN \(label)-----"
let endMarker = "-----END \(label)-----"
guard let beginRange = pem.range(of: beginMarker),
let endRange = pem.range(of: endMarker) else {
return nil
}
let base64String = pem[beginRange.upperBound..<endRange.lowerBound]
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
return Data(base64Encoded: base64String)
}
// MARK: - ASN.1 PKCS#8 / SPKI Wrappers
// SecKey on iOS exports RSA keys in PKCS#1 format, but Python expects PKCS#8 / SPKI.
// These functions add/remove the ASN.1 wrapping.
// RSA OID: 1.2.840.113549.1.1.1
private static let rsaOID: [UInt8] = [0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
private static let nullParam: [UInt8] = [0x05, 0x00]
/// Wrap PKCS#1 RSA private key in PKCS#8 PrivateKeyInfo envelope
private static func wrapRSAPrivateKeyPKCS8(_ pkcs1: Data) -> Data {
// PrivateKeyInfo ::= SEQUENCE {
// version INTEGER (0),
// algorithm AlgorithmIdentifier,
// privateKey OCTET STRING (containing PKCS#1 key)
// }
let version = Data([0x02, 0x01, 0x00]) // INTEGER 0
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
let privateKeyOctet = asn1OctetString(pkcs1)
return asn1Sequence(version + algorithmSeq + privateKeyOctet)
}
/// Unwrap PKCS#8 to get PKCS#1 RSA private key
private static func unwrapPKCS8ToRSAPrivateKey(_ pkcs8: Data) -> Data {
// Parse SEQUENCE, skip version + algorithm, extract OCTET STRING
guard pkcs8.count > 2 else { return pkcs8 }
var offset = 0
// Outer SEQUENCE
guard pkcs8[offset] == 0x30 else { return pkcs8 }
offset += 1
offset = skipASN1Length(pkcs8, offset: offset)
// Version INTEGER
guard offset < pkcs8.count, pkcs8[offset] == 0x02 else { return pkcs8 }
offset += 1
let versionLen = readASN1Length(pkcs8, offset: &offset)
offset += versionLen
// Algorithm SEQUENCE
guard offset < pkcs8.count, pkcs8[offset] == 0x30 else { return pkcs8 }
offset += 1
let algoLen = readASN1Length(pkcs8, offset: &offset)
offset += algoLen
// Private key OCTET STRING
guard offset < pkcs8.count, pkcs8[offset] == 0x04 else { return pkcs8 }
offset += 1
let keyLen = readASN1Length(pkcs8, offset: &offset)
guard offset + keyLen <= pkcs8.count else { return pkcs8 }
return Data(pkcs8[offset..<(offset + keyLen)])
}
/// Wrap PKCS#1 RSA public key in SubjectPublicKeyInfo
private static func wrapRSAPublicKeySPKI(_ pkcs1: Data) -> Data {
// SubjectPublicKeyInfo ::= SEQUENCE {
// algorithm AlgorithmIdentifier,
// subjectPublicKey BIT STRING (containing PKCS#1 key)
// }
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
let bitString = asn1BitString(pkcs1)
return asn1Sequence(algorithmSeq + bitString)
}
/// Unwrap SubjectPublicKeyInfo to get PKCS#1 RSA public key
private static func unwrapSPKIToRSAPublicKey(_ spki: Data) -> Data {
guard spki.count > 2 else { return spki }
var offset = 0
// Outer SEQUENCE
guard spki[offset] == 0x30 else { return spki }
offset += 1
offset = skipASN1Length(spki, offset: offset)
// Algorithm SEQUENCE
guard offset < spki.count, spki[offset] == 0x30 else { return spki }
offset += 1
let algoLen = readASN1Length(spki, offset: &offset)
offset += algoLen
// BIT STRING
guard offset < spki.count, spki[offset] == 0x03 else { return spki }
offset += 1
let bitLen = readASN1Length(spki, offset: &offset)
// Skip the unused bits byte
guard offset < spki.count, spki[offset] == 0x00 else { return spki }
offset += 1
let keyLen = bitLen - 1
guard offset + keyLen <= spki.count else { return spki }
return Data(spki[offset..<(offset + keyLen)])
}
// MARK: - ASN.1 Primitives
private static func asn1Length(_ length: Int) -> Data {
if length < 0x80 {
return Data([UInt8(length)])
} else if length <= 0xFF {
return Data([0x81, UInt8(length)])
} else if length <= 0xFFFF {
return Data([0x82, UInt8(length >> 8), UInt8(length & 0xFF)])
} else {
return Data([0x83, UInt8(length >> 16), UInt8((length >> 8) & 0xFF), UInt8(length & 0xFF)])
}
}
private static func asn1Sequence(_ content: Data) -> Data {
Data([0x30]) + asn1Length(content.count) + content
}
private static func asn1OctetString(_ content: Data) -> Data {
Data([0x04]) + asn1Length(content.count) + content
}
private static func asn1BitString(_ content: Data) -> Data {
// BIT STRING: tag + length + unused_bits(0) + content
Data([0x03]) + asn1Length(content.count + 1) + Data([0x00]) + content
}
private static func readASN1Length(_ data: Data, offset: inout Int) -> Int {
guard offset < data.count else { return 0 }
let first = data[offset]
offset += 1
if first < 0x80 {
return Int(first)
}
let numBytes = Int(first & 0x7F)
var length = 0
for _ in 0..<numBytes {
guard offset < data.count else { return length }
length = (length << 8) | Int(data[offset])
offset += 1
}
return length
}
private static func skipASN1Length(_ data: Data, offset: Int) -> Int {
var off = offset
_ = readASN1Length(data, offset: &off)
return off
}
}

View File

@@ -0,0 +1,175 @@
import Foundation
import CryptoKit
/// Sender key chain for group messaging.
/// Each sender in a group has their own chain. Others receive the initial key via pairwise ratchet.
/// Matches Python: SenderKeyState class in crypto_utils.py
class SenderKeyState {
let senderKey: Data
let chainId: Data
private(set) var chainKey: Data
private(set) var n: Int
private var knownKeys: [Int: Data]
/// Initialize with optional sender key (generates random 32B if nil).
/// Matches Python: SenderKeyState.__init__(sender_key=None)
init(senderKey: Data? = nil) {
let key = senderKey ?? Data.randomBytes(32)
self.senderKey = key
self.chainId = Data(SHA256.hash(data: key))
self.chainKey = CryptoUtils.hkdfDerive(
inputKey: key,
salt: Data(repeating: 0x00, count: 32),
info: Data(Constants.senderKeyChainInfo.utf8),
length: 32
)
self.n = 0
self.knownKeys = [:]
}
/// Private init for import
private init(senderKey: Data, chainId: Data, chainKey: Data, n: Int, knownKeys: [Int: Data]) {
self.senderKey = senderKey
self.chainId = chainId
self.chainKey = chainKey
self.n = n
self.knownKeys = knownKeys
}
// MARK: - Encrypt
/// Encrypt with current chain key.
/// Returns (chainId hex, n, ciphertext with tag, nonce).
/// Matches Python: SenderKeyState.encrypt(plaintext)
func encrypt(_ plaintext: Data) throws -> (chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) {
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: chainKey)
chainKey = newCK
let nonce = Data.randomBytes(12)
// AAD = chainId + bigEndian(UInt32(n))
let aad = chainId + UInt32(n).bigEndianData
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
let result = (chainIdHex: chainId.hexString, n: n, ciphertext: ctWithTag, nonce: nonce)
n += 1
return result
}
// MARK: - Decrypt
/// Decrypt a group message. Fast-forwards the chain if needed.
/// State is snapshotted before modification and restored on failure.
/// Matches Python: SenderKeyState.decrypt(chain_id_hex, n, ciphertext, nonce)
func decrypt(chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) throws -> Data {
guard let expectedChainId = Data(hexString: chainIdHex) else {
throw CryptoError.senderKeyError("Invalid chain ID hex")
}
guard expectedChainId == chainId else {
throw CryptoError.senderKeyError("Chain ID mismatch")
}
if n - self.n > Constants.maxSenderKeySkip {
throw CryptoError.senderKeyError("Sender key skip too large (\(n - self.n) > \(Constants.maxSenderKeySkip))")
}
// Snapshot before fast-forward
let snapChainKey = chainKey
let snapN = self.n
let snapKnown = knownKeys
do {
// Fast-forward the chain to reach message n
while self.n <= n {
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: chainKey)
chainKey = newCK
knownKeys[self.n] = mk
self.n += 1
}
guard let mk = knownKeys.removeValue(forKey: n) else {
throw CryptoError.senderKeyError("Message key for n=\(n) not available")
}
let aad = chainId + UInt32(n).bigEndianData
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
} catch {
// Restore state on failure
chainKey = snapChainKey
self.n = snapN
knownKeys = snapKnown
throw error
}
}
// MARK: - Key Export/Import
/// Export sender key for distribution to group members.
/// Matches Python: SenderKeyState.export_key()
func exportKey() -> Data {
let dict: [String: Any] = ["sender_key": senderKey.hexString]
return try! JSONSerialization.data(withJSONObject: dict)
}
/// Initialize a receiving SenderKeyState from an exported key.
/// Matches Python: SenderKeyState.from_key(exported_key)
static func fromKey(_ exportedKey: Data) throws -> SenderKeyState {
guard let dict = try JSONSerialization.jsonObject(with: exportedKey) as? [String: Any],
let senderKeyHex = dict["sender_key"] as? String,
let senderKey = Data(hexString: senderKeyHex) else {
throw CryptoError.stateImportFailed("Invalid sender key export")
}
return SenderKeyState(senderKey: senderKey)
}
// MARK: - Full State Export/Import
/// Serialize full state for persistent storage.
/// Matches Python: SenderKeyState.export_state()
func exportState() -> Data {
var knownKeysDict: [String: String] = [:]
for (k, v) in knownKeys {
knownKeysDict[String(k)] = v.hexString
}
let state: [String: Any] = [
"sender_key": senderKey.hexString,
"chain_id": chainId.hexString,
"chain_key": chainKey.hexString,
"n": n,
"known_keys": knownKeysDict,
]
return try! JSONSerialization.data(withJSONObject: state)
}
/// Deserialize full state.
/// Matches Python: SenderKeyState.import_state(data)
static func importState(_ data: Data) throws -> SenderKeyState {
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let senderKeyHex = state["sender_key"] as? String,
let senderKey = Data(hexString: senderKeyHex),
let chainIdHex = state["chain_id"] as? String,
let chainId = Data(hexString: chainIdHex),
let chainKeyHex = state["chain_key"] as? String,
let chainKey = Data(hexString: chainKeyHex),
let n = state["n"] as? Int else {
throw CryptoError.stateImportFailed("Invalid sender key state")
}
var knownKeys: [Int: Data] = [:]
if let knownKeysDict = state["known_keys"] as? [String: String] {
for (k, v) in knownKeysDict {
if let idx = Int(k), let data = Data(hexString: v) {
knownKeys[idx] = data
}
}
}
return SenderKeyState(
senderKey: senderKey,
chainId: chainId,
chainKey: chainKey,
n: n,
knownKeys: knownKeys
)
}
}

View File

@@ -0,0 +1,77 @@
import Foundation
import CryptoKit
/// X25519 Diffie-Hellman key agreement
enum X25519Crypto {
// MARK: - Key Generation
/// Generate X25519 keypair
static func generateKeypair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {
let privateKey = Curve25519.KeyAgreement.PrivateKey()
return (privateKey, privateKey.publicKey)
}
// MARK: - Serialization
/// Serialize X25519 private key to 32 raw bytes
static func serializePrivate(_ key: Curve25519.KeyAgreement.PrivateKey) -> Data {
key.rawData // 32 bytes
}
/// Serialize X25519 public key to 32 raw bytes
static func serializePublic(_ key: Curve25519.KeyAgreement.PublicKey) -> Data {
key.rawData // 32 bytes
}
/// Load X25519 private key from 32 raw bytes
static func loadPrivate(_ data: Data) throws -> Curve25519.KeyAgreement.PrivateKey {
guard data.count == 32 else {
throw CryptoError.invalidKeyData("X25519 private key must be 32 bytes")
}
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data)
}
/// Load X25519 public key from 32 raw bytes
static func loadPublic(_ data: Data) throws -> Curve25519.KeyAgreement.PublicKey {
guard data.count == 32 else {
throw CryptoError.invalidKeyData("X25519 public key must be 32 bytes")
}
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
}
// MARK: - Diffie-Hellman
/// Perform X25519 DH key agreement. Returns 32-byte shared secret.
/// Matches Python: x25519_dh(private_key, public_key)
static func dh(_ privateKey: Curve25519.KeyAgreement.PrivateKey, _ publicKey: Curve25519.KeyAgreement.PublicKey) throws -> Data {
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
// Extract raw bytes from SharedSecret
return sharedSecret.withUnsafeBytes { Data($0) }
}
// MARK: - Ed25519 X25519 Key Conversion
/// Convert Ed25519 private key to X25519 private key.
/// SHA-512(seed) take first 32 bytes clamp per RFC 7748
/// Matches Python: ed25519_private_to_x25519(ed_private)
static func fromEd25519Private(_ edPrivate: Curve25519.Signing.PrivateKey) throws -> Curve25519.KeyAgreement.PrivateKey {
let raw = edPrivate.rawData // 32 bytes seed
// SHA-512 of the seed
let hash = SHA512.hash(data: raw)
var clamped = Data(hash.prefix(32))
// Clamp per RFC 7748
clamped[0] &= 248
clamped[31] &= 127
clamped[31] |= 64
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: clamped)
}
/// Convert Ed25519 public key to X25519 public key.
/// Uses Montgomery birational map: u = (1+y)/(1-y) mod p
/// Matches Python: ed25519_public_to_x25519(ed_public)
static func fromEd25519Public(_ edPublic: Curve25519.Signing.PublicKey) throws -> Curve25519.KeyAgreement.PublicKey {
let x25519Bytes = FieldArithmetic.ed25519PublicToX25519(edPublic.rawData)
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: x25519Bytes)
}
}

View File

@@ -0,0 +1,139 @@
import Foundation
import CryptoKit
/// X3DH key agreement protocol (Signal Protocol)
enum X3DH {
// MARK: - Pre-Key Generation
/// Generate a signed pre-key (SPK).
/// Returns (private, public, signature, id).
/// Matches Python: generate_signed_prekey(identity_private)
static func generateSignedPrekey(
identityPrivate: Curve25519.Signing.PrivateKey
) throws -> (privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey,
signature: Data,
id: String) {
let (spkPriv, spkPub) = X25519Crypto.generateKeypair()
let spkPubBytes = X25519Crypto.serializePublic(spkPub)
let signature = try Ed25519Crypto.sign(identityPrivate, data: spkPubBytes)
return (spkPriv, spkPub, signature, UUID().uuidString)
}
/// Generate a batch of one-time pre-keys.
/// Matches Python: generate_one_time_prekeys(count=50)
static func generateOneTimePrekeys(count: Int = 50) -> [(privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey,
id: String)] {
(0..<count).map { _ in
let (priv, pub) = X25519Crypto.generateKeypair()
return (priv, pub, UUID().uuidString)
}
}
// MARK: - X3DH Initiate (Alice)
/// Initiator side of X3DH.
/// Returns (sharedSecret, ephemeralPrivate, ephemeralPublic).
/// Matches Python: x3dh_initiate(ik_private_ed, ik_public_remote_ed, spk_remote, spk_signature, opk_remote?)
static func initiate(
ikPrivateEd: Curve25519.Signing.PrivateKey,
ikPublicRemoteEd: Curve25519.Signing.PublicKey,
spkRemote: Curve25519.KeyAgreement.PublicKey,
spkSignature: Data,
opkRemote: Curve25519.KeyAgreement.PublicKey? = nil
) throws -> (sharedSecret: Data,
ephemeralPrivate: Curve25519.KeyAgreement.PrivateKey,
ephemeralPublic: Curve25519.KeyAgreement.PublicKey) {
// Verify SPK signature
let spkRemoteBytes = X25519Crypto.serializePublic(spkRemote)
guard Ed25519Crypto.verify(ikPublicRemoteEd, signature: spkSignature, data: spkRemoteBytes) else {
throw CryptoError.x3dhFailed("Invalid SPK signature")
}
// Convert identity keys to X25519
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikPublicRemoteEd)
// Generate ephemeral keypair
let (ekPriv, ekPub) = X25519Crypto.generateKeypair()
// Debug: print key inputs (matching Python x3dh_respond)
#if DEBUG
print("DEBUG x3dh_initiate: ik_remote_ed = \(Ed25519Crypto.serializePublic(ikPublicRemoteEd).hexString)")
print("DEBUG x3dh_initiate: ik_x25519_remote = \(X25519Crypto.serializePublic(ikX25519Remote).hexString)")
print("DEBUG x3dh_initiate: ek_pub = \(X25519Crypto.serializePublic(ekPub).hexString)")
print("DEBUG x3dh_initiate: spk_remote = \(spkRemoteBytes.hexString)")
#endif
// DH computations
let dh1 = try X25519Crypto.dh(ikX25519Private, spkRemote) // IK_A, SPK_B
let dh2 = try X25519Crypto.dh(ekPriv, ikX25519Remote) // EK_A, IK_B
let dh3 = try X25519Crypto.dh(ekPriv, spkRemote) // EK_A, SPK_B
// Debug: print DH outputs
#if DEBUG
print("DEBUG x3dh_initiate: dh1 = \(dh1.hexString)")
print("DEBUG x3dh_initiate: dh2 = \(dh2.hexString)")
print("DEBUG x3dh_initiate: dh3 = \(dh3.hexString)")
#endif
var dhConcat = dh1 + dh2 + dh3
if let opk = opkRemote {
let dh4 = try X25519Crypto.dh(ekPriv, opk) // EK_A, OPK_B
#if DEBUG
print("DEBUG x3dh_initiate: dh4 = \(dh4.hexString)")
#endif
dhConcat += dh4
}
// Derive shared secret
let sharedSecret = CryptoUtils.hkdfDerive(
inputKey: dhConcat,
salt: Data(repeating: 0x00, count: 32),
info: Data(Constants.x3dhInfo.utf8),
length: 32
)
#if DEBUG
print("DEBUG x3dh_initiate: shared_secret = \(sharedSecret.hexString)")
#endif
return (sharedSecret, ekPriv, ekPub)
}
// MARK: - X3DH Respond (Bob)
/// Responder side of X3DH.
/// Returns sharedSecret.
/// Matches Python: x3dh_respond(ik_private_ed, spk_private, ik_remote_ed, ek_remote, opk_private?)
static func respond(
ikPrivateEd: Curve25519.Signing.PrivateKey,
spkPrivate: Curve25519.KeyAgreement.PrivateKey,
ikRemoteEd: Curve25519.Signing.PublicKey,
ekRemote: Curve25519.KeyAgreement.PublicKey,
opkPrivate: Curve25519.KeyAgreement.PrivateKey? = nil
) throws -> Data {
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikRemoteEd)
let dh1 = try X25519Crypto.dh(spkPrivate, ikX25519Remote) // SPK_B, IK_A
let dh2 = try X25519Crypto.dh(ikX25519Private, ekRemote) // IK_B, EK_A
let dh3 = try X25519Crypto.dh(spkPrivate, ekRemote) // SPK_B, EK_A
var dhConcat = dh1 + dh2 + dh3
if let opk = opkPrivate {
let dh4 = try X25519Crypto.dh(opk, ekRemote) // OPK_B, EK_A
dhConcat += dh4
}
let sharedSecret = CryptoUtils.hkdfDerive(
inputKey: dhConcat,
salt: Data(repeating: 0x00, count: 32),
info: Data(Constants.x3dhInfo.utf8),
length: 32
)
return sharedSecret
}
}

View File

@@ -0,0 +1,47 @@
import SwiftUI
@main
struct KecalekApp: App {
@State private var appState = AppState()
@State private var authViewModel = AuthViewModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
if appState.isLoggedIn {
MainTabView(appState: appState)
} else {
LoginView(viewModel: authViewModel, appState: appState)
}
}
.onChange(of: scenePhase) { _, newPhase in
switch newPhase {
case .background:
appState.handleEnteredBackground()
case .active:
appState.handleBecameActive()
default:
break
}
}
}
}
struct MainTabView: View {
var appState: AppState
@State private var convListVM = ConversationListVM()
var body: some View {
TabView {
ConversationListView(appState: appState, viewModel: convListVM)
.tabItem {
Label("Chats", systemImage: "bubble.left.and.bubble.right.fill")
}
ProfileView(appState: appState, isOwnProfile: true)
.tabItem {
Label("Profile", systemImage: "person.fill")
}
}
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
struct Conversation: Identifiable, Equatable, Hashable, Codable {
let id: String
var name: String?
var members: [ConversationMember]
var createdBy: String?
var avatarFile: String?
var unreadCount: Int
var isFavorite: Bool
var lastMessageTime: Date?
var isGroup: Bool {
name != nil || members.count > 2
}
/// Display name: group name, or DM partner username
func displayName(currentUserId: String) -> String {
if let name = name, !name.isEmpty {
return name
}
// DM: show the other person's name
if let other = members.first(where: { $0.userId != currentUserId }) {
return other.username
}
return "Unknown"
}
/// DM partner user ID (nil for groups)
func dmPartnerId(currentUserId: String) -> String? {
guard !isGroup else { return nil }
return members.first(where: { $0.userId != currentUserId })?.userId
}
static func == (lhs: Conversation, rhs: Conversation) -> Bool {
lhs.id == rhs.id
&& lhs.name == rhs.name
&& lhs.members == rhs.members
&& lhs.avatarFile == rhs.avatarFile
&& lhs.unreadCount == rhs.unreadCount
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct ConversationMember: Identifiable, Equatable, Codable {
let userId: String
var username: String
var email: String
var id: String { userId }
}

View File

@@ -0,0 +1,69 @@
import Foundation
/// Key bundle for one device, used in X3DH
struct DeviceBundle {
let deviceId: String
let identityKey: Data // Ed25519 public key (32 bytes)
let spk: Data // X25519 public key (32 bytes)
let spkSignature: Data // Ed25519 signature (64 bytes)
let spkId: String
let opk: Data? // X25519 public key (32 bytes), optional
let opkId: String?
/// Parse from server response dictionary
/// Server uses: signed_prekey, signed_prekey_id, one_time_prekey, one_time_prekey_id (base64)
static func fromDict(_ dict: [String: Any], identityKey: Data? = nil) throws -> DeviceBundle {
guard let deviceId = dict["device_id"] as? String else {
throw ChatError.invalidData("Missing device_id")
}
// Identity key can be passed in (from parent) or in dict
let ik: Data
if let passedIk = identityKey {
ik = passedIk
} else if let ikB64 = dict["identity_key"] as? String,
let ikData = Data(base64Encoded: ikB64) {
ik = ikData
} else {
throw ChatError.invalidData("Missing identity_key")
}
// SPK - try both naming conventions, base64 encoded
let spkB64 = dict["signed_prekey"] as? String ?? dict["spk"] as? String
guard let spkB64 = spkB64,
let spk = Data(base64Encoded: spkB64) else {
throw ChatError.invalidData("Missing signed_prekey")
}
// SPK signature - base64 encoded
guard let spkSigB64 = dict["spk_signature"] as? String,
let spkSig = Data(base64Encoded: spkSigB64) else {
throw ChatError.invalidData("Missing spk_signature")
}
// SPK ID - try both naming conventions
let spkId = dict["signed_prekey_id"] as? String ?? dict["spk_id"] as? String
guard let spkId = spkId else {
throw ChatError.invalidData("Missing signed_prekey_id")
}
// OPK - optional, base64 encoded
var opk: Data?
var opkId: String?
let opkB64 = dict["one_time_prekey"] as? String ?? dict["opk"] as? String
if let opkB64 = opkB64, let opkData = Data(base64Encoded: opkB64) {
opk = opkData
opkId = dict["one_time_prekey_id"] as? String ?? dict["opk_id"] as? String
}
return DeviceBundle(
deviceId: deviceId,
identityKey: ik,
spk: spk,
spkSignature: spkSig,
spkId: spkId,
opk: opk,
opkId: opkId
)
}
}

View File

@@ -0,0 +1,9 @@
import Foundation
struct Invitation: Identifiable {
let id: String // invitation id (from server) or conversationId
let conversationId: String
let conversationName: String
let invitedBy: String
let invitedByUsername: String
}

View File

@@ -0,0 +1,210 @@
import Foundation
struct MessageReaction: Equatable {
let userId: String
let reaction: String
let createdAt: Date
}
struct ForwardedFrom: Equatable {
let sender: String
let conversationId: String
let messageId: String
}
enum ReactionEmoji {
static let allowed = ["thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"]
static let display: [String: String] = [
"thumbsup": "👍", "heart": "❤️", "laugh": "😂",
"surprised": "😮", "sad": "😢", "thumbsdown": "👎",
]
}
struct Message: Identifiable, Equatable {
let id: String
let conversationId: String
let senderId: String
var senderUsername: String
let createdAt: Date
var text: String?
var replyTo: String?
var imageFileId: String?
var file: FileInfo?
var image: ImageInfo?
var isDeleted: Bool
var readBy: Set<String>
var reactions: [MessageReaction]
var forwardedFrom: ForwardedFrom?
var pinnedAt: Date?
var pinnedBy: String?
/// Whether this is a self-sent message
func isMine(currentUserId: String) -> Bool {
senderId == currentUserId
}
static func == (lhs: Message, rhs: Message) -> Bool {
lhs.id == rhs.id
}
}
struct FileInfo: Equatable, Codable {
let fileId: String
let aesKey: String // base64
let iv: String // base64
let filename: String
let size: Int
let mimeType: String
}
struct ImageInfo: Equatable {
let fileId: String
let aesKey: String // base64
let iv: String // base64
let thumbnail: String? // base64 JPEG thumbnail
let filename: String
let size: Int
}
// MARK: - Cache Dictionary Conversion
extension Message {
/// Convert to dictionary matching server JSON format for MessageCache storage
func toCacheDict() -> [String: Any] {
var dict: [String: Any] = [
"message_id": id,
"conversation_id": conversationId,
"sender_id": senderId,
"sender_username": senderUsername,
"created_at": DateParsing.format(createdAt),
"is_deleted": isDeleted,
]
if let text = text { dict["text"] = text }
if let replyTo = replyTo { dict["reply_to"] = replyTo }
if let imageFileId = imageFileId { dict["image_file_id"] = imageFileId }
if let file = file {
dict["file"] = [
"file_id": file.fileId,
"aes_key": file.aesKey,
"iv": file.iv,
"filename": file.filename,
"size": file.size,
"mime_type": file.mimeType,
] as [String: Any]
}
if let image = image {
var imgDict: [String: Any] = [
"file_id": image.fileId,
"aes_key": image.aesKey,
"iv": image.iv,
"filename": image.filename,
"size": image.size,
]
if let thumbnail = image.thumbnail { imgDict["thumbnail"] = thumbnail }
dict["image"] = imgDict
}
if !readBy.isEmpty { dict["read_by"] = Array(readBy) }
if !reactions.isEmpty {
dict["reactions"] = reactions.map {
["user_id": $0.userId, "reaction": $0.reaction,
"created_at": DateParsing.format($0.createdAt)] as [String: Any]
}
}
if let fwd = forwardedFrom {
dict["forwarded_from"] = ["sender": fwd.sender,
"conversation_id": fwd.conversationId,
"message_id": fwd.messageId] as [String: Any]
}
if let pinnedAt { dict["pinned_at"] = DateParsing.format(pinnedAt) }
if let pinnedBy { dict["pinned_by"] = pinnedBy }
return dict
}
/// Create Message from cache dictionary (server JSON format)
static func fromCacheDict(_ dict: [String: Any]) -> Message? {
guard let id = dict["message_id"] as? String,
let conversationId = dict["conversation_id"] as? String,
let senderId = dict["sender_id"] as? String,
let createdAtStr = dict["created_at"] as? String,
let createdAt = DateParsing.parse(createdAtStr) else {
return nil
}
let senderUsername = dict["sender_username"] as? String ?? ""
var file: FileInfo?
if let fileDict = dict["file"] as? [String: Any],
let fileId = fileDict["file_id"] as? String {
file = FileInfo(
fileId: fileId,
aesKey: fileDict["aes_key"] as? String ?? "",
iv: fileDict["iv"] as? String ?? "",
filename: fileDict["filename"] as? String ?? "",
size: fileDict["size"] as? Int ?? 0,
mimeType: fileDict["mime_type"] as? String ?? ""
)
}
var image: ImageInfo?
if let imgDict = dict["image"] as? [String: Any],
let imgFileId = imgDict["file_id"] as? String {
image = ImageInfo(
fileId: imgFileId,
aesKey: imgDict["aes_key"] as? String ?? "",
iv: imgDict["iv"] as? String ?? "",
thumbnail: imgDict["thumbnail"] as? String,
filename: imgDict["filename"] as? String ?? "image.jpg",
size: imgDict["size"] as? Int ?? 0
)
}
let readBy: Set<String>
if let readByArray = dict["read_by"] as? [String] {
readBy = Set(readByArray)
} else {
readBy = []
}
var reactions: [MessageReaction] = []
if let reactionsArr = dict["reactions"] as? [[String: Any]] {
reactions = reactionsArr.compactMap { r in
guard let userId = r["user_id"] as? String,
let reaction = r["reaction"] as? String else { return nil }
let createdAt = (r["created_at"] as? String).flatMap { DateParsing.parse($0) } ?? Date()
return MessageReaction(userId: userId, reaction: reaction, createdAt: createdAt)
}
}
var forwardedFrom: ForwardedFrom?
if let fwd = dict["forwarded_from"] as? [String: Any],
let sender = fwd["sender"] as? String {
forwardedFrom = ForwardedFrom(
sender: sender,
conversationId: fwd["conversation_id"] as? String ?? "",
messageId: fwd["message_id"] as? String ?? ""
)
}
let pinnedAt = (dict["pinned_at"] as? String).flatMap { DateParsing.parse($0) }
let pinnedBy = dict["pinned_by"] as? String
return Message(
id: id,
conversationId: conversationId,
senderId: senderId,
senderUsername: senderUsername,
createdAt: createdAt,
text: dict["text"] as? String,
replyTo: dict["reply_to"] as? String,
imageFileId: dict["image_file_id"] as? String,
file: file,
image: image,
isDeleted: dict["is_deleted"] as? Bool ?? false,
readBy: readBy,
reactions: reactions,
forwardedFrom: forwardedFrom,
pinnedAt: pinnedAt,
pinnedBy: pinnedBy
)
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
struct User: Identifiable, Equatable {
let id: String
var username: String
var email: String
var identityKey: Data? // Ed25519 public key (32 bytes)
}
struct UserProfile: Equatable {
var userId: String
var username: String?
var email: String?
var phone: String?
var phoneVisible: Bool
var location: String?
var locationVisible: Bool
var avatarFile: String?
}

View File

@@ -0,0 +1,191 @@
import Foundation
import Network
/// TCP connection manager using Network.framework.
/// Handles connection lifecycle, TLS, buffered reading (newline-delimited), and writing.
actor ConnectionManager {
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case failed(String)
}
private var connection: NWConnection?
private var receiveBuffer = Data()
private(set) var state: ConnectionState = .disconnected
private var stateCallback: ((ConnectionState) -> Void)?
private var messageStream: AsyncStream<[String: Any]>.Continuation?
/// Set a callback for connection state changes
func onStateChange(_ callback: @escaping (ConnectionState) -> Void) {
stateCallback = callback
}
// MARK: - Connect / Disconnect
/// Connect to server
func connect(host: String, port: UInt16) async throws {
guard state == .disconnected || state != .connected else {
throw NetworkError.alreadyConnected
}
updateState(.connecting)
let nwHost = NWEndpoint.Host(host)
let nwPort = NWEndpoint.Port(rawValue: port)!
let tlsOptions = NWProtocolTLS.Options()
let params = NWParameters(tls: tlsOptions, tcp: .init())
let conn = NWConnection(host: nwHost, port: nwPort, using: params)
self.connection = conn
self.receiveBuffer = Data()
return try await withCheckedThrowingContinuation { continuation in
// nonisolated flag accessed only from the stateUpdateHandler serial queue
// Use a class wrapper so the closure can mutate it
final class ResumedFlag: @unchecked Sendable {
var value = false
}
let resumed = ResumedFlag()
conn.stateUpdateHandler = { [weak self] newState in
Task { [weak self] in
guard let self = self else { return }
switch newState {
case .ready:
await self.updateState(.connected)
guard !resumed.value else { return }
resumed.value = true
continuation.resume()
case .failed(let error):
await self.updateState(.failed(error.localizedDescription))
guard !resumed.value else { return }
resumed.value = true
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
case .cancelled:
await self.updateState(.disconnected)
guard !resumed.value else { return }
resumed.value = true
continuation.resume(throwing: NetworkError.connectionFailed("Connection cancelled"))
case .waiting(let error):
await self.updateState(.failed(error.localizedDescription))
guard !resumed.value else { return }
resumed.value = true
continuation.resume(throwing: NetworkError.connectionFailed("Waiting: \(error.localizedDescription)"))
default:
break
}
}
}
conn.start(queue: .global(qos: .userInitiated))
}
}
/// Disconnect from server
func disconnect() {
connection?.cancel()
connection = nil
receiveBuffer = Data()
updateState(.disconnected)
messageStream?.finish()
messageStream = nil
}
// MARK: - Send
/// Send raw data over the connection
func send(_ data: Data) async throws {
guard let connection = connection, state == .connected else {
throw NetworkError.notConnected
}
return try await withCheckedThrowingContinuation { continuation in
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
} else {
continuation.resume()
}
})
}
}
/// Send a protocol message (builds JSON + newline, sends)
func sendMessage(type: String, requestId: String? = nil, params: [String: Any] = [:]) async throws {
let data = try ProtocolHandler.buildRequest(type: type, requestId: requestId, params: params)
try await send(data)
}
// MARK: - Receive
/// Read one newline-delimited JSON message.
/// Returns nil on EOF / connection close.
func readMessage() async throws -> [String: Any]? {
while true {
// Check buffer for a complete line
if let newlineIndex = receiveBuffer.firstIndex(of: 0x0A) {
let lineData = receiveBuffer.prefix(through: newlineIndex)
receiveBuffer.removeSubrange(...newlineIndex)
// Check size
if lineData.count > Constants.maxMessageBytes {
throw NetworkError.messageTooLarge
}
return try ProtocolHandler.parseMessage(Data(lineData))
}
// Buffer doesn't have a complete line read more from the connection
guard let connection = connection else {
return nil
}
let chunk = try await receiveChunk(connection: connection)
guard let chunk = chunk else {
return nil // EOF
}
receiveBuffer.append(chunk)
// Safety: if buffer exceeds max without a newline, drop it
if receiveBuffer.count > Constants.maxMessageBytes * 2 {
receiveBuffer = Data()
throw NetworkError.messageTooLarge
}
}
}
/// Read a chunk of data from the connection
private func receiveChunk(connection: NWConnection) async throws -> Data? {
return try await withCheckedThrowingContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, isComplete, error in
if let error = error {
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
return
}
if let content = content, !content.isEmpty {
continuation.resume(returning: content)
} else if isComplete {
continuation.resume(returning: nil)
} else {
// No data and not complete shouldn't happen but return nil
continuation.resume(returning: nil)
}
}
}
}
// MARK: - State
var isConnected: Bool {
state == .connected
}
private func updateState(_ newState: ConnectionState) {
state = newState
stateCallback?(newState)
}
}

View File

@@ -0,0 +1,88 @@
import Foundation
/// Newline-delimited JSON protocol handler.
/// Matches Python: protocol.py build_request, build_response, parse_message, encode_binary, decode_binary
enum ProtocolHandler: Sendable {
/// Build a request message (newline-terminated JSON).
/// Matches Python: build_request(msg_type, request_id=None, **kwargs)
nonisolated static func buildRequest(type: String, requestId: String? = nil, params: [String: Any] = [:]) throws -> Data {
var msg: [String: Any] = ["type": type]
if let requestId = requestId {
msg["request_id"] = requestId
}
// Merge params into msg
for (key, value) in params {
msg[key] = value
}
let jsonData = try JSONSerialization.data(withJSONObject: msg)
guard jsonData.count < Constants.maxMessageBytes else {
throw NetworkError.messageTooLarge
}
return jsonData + Data([0x0A]) // newline
}
/// Build a response message (newline-terminated JSON).
nonisolated static func buildResponse(type: String, status: String, data: [String: Any]? = nil, requestId: String? = nil) throws -> Data {
var msg: [String: Any] = ["type": type, "status": status]
if let data = data {
msg["data"] = data
}
if let requestId = requestId {
msg["request_id"] = requestId
}
let jsonData = try JSONSerialization.data(withJSONObject: msg)
guard jsonData.count < Constants.maxMessageBytes else {
throw NetworkError.messageTooLarge
}
return jsonData + Data([0x0A])
}
/// Parse a single protocol message from bytes.
/// Matches Python: parse_message(line)
nonisolated static func parseMessage(_ data: Data) throws -> [String: Any] {
let trimmed = Self.trimmingNewlines(data)
guard !trimmed.isEmpty else {
throw NetworkError.protocolError("Empty message")
}
guard let obj = try JSONSerialization.jsonObject(with: trimmed) as? [String: Any] else {
throw NetworkError.protocolError("Message is not a JSON object")
}
return obj
}
/// Encode bytes to base64 string.
/// Matches Python: encode_binary(data)
nonisolated static func encodeBinary(_ data: Data) -> String {
data.base64EncodedString(options: [])
}
/// Decode base64 string to bytes.
/// Matches Python: decode_binary(data)
nonisolated static func decodeBinary(_ string: String) throws -> Data {
guard let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) else {
throw CryptoError.invalidBase64
}
return data
}
/// Generate a new request ID (UUID string).
nonisolated static func newRequestId() -> String {
UUID().uuidString
}
// MARK: - Helpers
private nonisolated static func trimmingNewlines(_ data: Data) -> Data {
var result = data
while let last = result.last, last == 0x0A || last == 0x0D {
result.removeLast()
}
while let first = result.first, first == 0x0A || first == 0x0D {
result.removeFirst()
}
return result
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
enum Constants: Sendable {
nonisolated static let version = "0.8.5"
nonisolated static let maxMessageBytes = 65536
nonisolated static let maxImageBytes = 5 * 1024 * 1024 // 5 MB
nonisolated static let maxFileBytes = 50 * 1024 * 1024 // 50 MB
nonisolated static let imageChunkSize = 32768 // 32 KB (matches Python IMAGE_CHUNK_SIZE)
nonisolated static let selfDeviceId = "00000000-0000-0000-0000-000000000000"
nonisolated static let opkReplenishThreshold = 20
nonisolated static let opkBatchSize = 50
nonisolated static let spkRotationDays = 7
nonisolated static let maxSkip = 256
nonisolated static let maxSenderKeySkip = 256
nonisolated static let deviceBundleCacheTTL: TimeInterval = 300 // 5 minutes
nonisolated static let sendReceiveTimeout: TimeInterval = 30
nonisolated static let reconnectBaseDelay: TimeInterval = 1
nonisolated static let reconnectMaxDelay: TimeInterval = 30
nonisolated static let pbkdf2Iterations: UInt32 = 600_000
nonisolated static let ecp1Magic = Data([0x45, 0x43, 0x50, 0x31]) // "ECP1"
// HKDF info/salt strings matching Python
nonisolated static let x3dhInfo = "EncryptedChat_X3DH"
nonisolated static let rootKeyInfo = "EncryptedChat_RootKey"
nonisolated static let selfEncryptionSalt = "self_encryption"
nonisolated static let selfEncryptionInfo = "EncryptedChat_SelfKey"
nonisolated static let localStorageSalt = "local_storage"
nonisolated static let localStorageInfo = "EncryptedChat_LocalStorage"
nonisolated static let senderKeyChainInfo = "SenderKeyChain"
// Server connection defaults
nonisolated static let defaultHost = "chat.ai-tech.news"
nonisolated static let defaultPort: UInt16 = 9999
}

View File

@@ -0,0 +1,168 @@
import Foundation
import CryptoKit
// MARK: - Data Hex
extension Data {
/// Convert data to lowercase hex string
var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
/// Initialize Data from a hex string
init?(hexString: String) {
let hex = hexString.lowercased()
guard hex.count % 2 == 0 else { return nil }
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index..<nextIndex], radix: 16) else { return nil }
data.append(byte)
index = nextIndex
}
self = data
}
/// Generate random bytes
static func randomBytes(_ count: Int) -> Data {
var data = Data(count: count)
data.withUnsafeMutableBytes { ptr in
_ = SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
}
return data
}
}
// MARK: - Data Base64 (Protocol Wire Format)
extension Data {
/// Encode to standard base64 string (matching Python's base64.b64encode)
func base64EncodedString() -> String {
self.base64EncodedString(options: [])
}
/// Decode from base64 string
static func fromBase64(_ string: String) throws -> Data {
// Try standard base64 first, then URL-safe
if let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) {
return data
}
throw CryptoError.invalidBase64
}
}
// MARK: - UInt32 Big-Endian
extension UInt32 {
var bigEndianData: Data {
var value = self.bigEndian
return Data(bytes: &value, count: 4)
}
}
// MARK: - CryptoKit Key Data
extension Curve25519.KeyAgreement.PublicKey {
nonisolated var rawData: Data {
Data(rawRepresentation)
}
}
extension Curve25519.KeyAgreement.PrivateKey {
nonisolated var rawData: Data {
Data(rawRepresentation)
}
}
extension Curve25519.Signing.PublicKey {
nonisolated var rawData: Data {
Data(rawRepresentation)
}
}
extension Curve25519.Signing.PrivateKey {
nonisolated var rawData: Data {
Data(rawRepresentation)
}
}
// MARK: - String helpers
extension String {
/// Trim whitespace and newlines
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}
// MARK: - Date Parsing (server sends ISO8601 with or without timezone)
enum DateParsing {
private static let iso8601WithTZ: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let iso8601Basic: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private static let noTZ: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
f.timeZone = TimeZone(identifier: "UTC")
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
/// Parse ISO8601 date string handles with/without timezone, with/without fractional seconds
static func parse(_ string: String) -> Date? {
iso8601WithTZ.date(from: string)
?? iso8601Basic.date(from: string)
?? noTZ.date(from: string)
}
/// Format Date to ISO8601 string (for after_ts / since_ts parameters)
static func format(_ date: Date) -> String {
iso8601WithTZ.string(from: date)
}
}
// MARK: - Dictionary merge helper
extension Dictionary where Key == String, Value == Any {
nonisolated func string(for key: String) -> String? {
self[key] as? String
}
nonisolated func int(for key: String) -> Int? {
if let i = self[key] as? Int { return i }
if let s = self[key] as? String, let i = Int(s) { return i }
return nil
}
nonisolated func dict(for key: String) -> [String: Any]? {
self[key] as? [String: Any]
}
nonisolated func array(for key: String) -> [[String: Any]]? {
self[key] as? [[String: Any]]
}
nonisolated func data(for key: String) -> Data? {
if let hex = self[key] as? String {
return Data(hexString: hex)
}
return nil
}
nonisolated func bool(for key: String) -> Bool? {
if let b = self[key] as? Bool { return b }
if let i = self[key] as? Int { return i != 0 }
return nil
}
}

View File

@@ -0,0 +1,192 @@
import Foundation
import SwiftUI
@Observable
final class AuthViewModel {
var email = ""
var password = ""
var confirmPassword = ""
var username = ""
var confirmationCode = ""
var isLoading = false
var errorMessage: String?
var showConfirmation = false
var registrationMessage: String?
var serverHost = Constants.defaultHost
var serverPort = String(Constants.defaultPort)
var hasSavedCredentials = false
var isBiometricLoading = false
enum AuthMode {
case login, register, pairing
}
var mode: AuthMode = .login
func checkSavedCredentials() {
hasSavedCredentials = KeychainService.hasSavedCredentials() && KeychainService.isBiometricAvailable()
}
func login(appState: AppState) async {
guard !email.isEmpty, !password.isEmpty else {
errorMessage = "Email and password are required"
return
}
isLoading = true
errorMessage = nil
// Only connect if not already connected
if await !appState.chatClient.isConnected {
do {
let port = UInt16(serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(host: serverHost, port: port)
} catch {
isLoading = false
errorMessage = "Connection failed: \(error.localizedDescription)"
return
}
}
let (success, message) = await appState.chatClient.login(email: email, password: password)
isLoading = false
if success {
appState.email = email
appState.isLoggedIn = true
appState.connectionStatus = .connected
appState.startConnectionMonitor()
if let userId = await appState.chatClient.userId {
appState.currentUser = User(id: userId, username: await appState.chatClient.username, email: email)
}
// Save credentials for biometric login next time
if KeychainService.isBiometricAvailable() {
let port = UInt16(serverPort) ?? Constants.defaultPort
try? KeychainService.saveCredentials(
email: email, password: password,
host: serverHost, port: port
)
}
// Clear password from memory after successful login
password = ""
confirmPassword = ""
} else {
errorMessage = message
}
}
func biometricLogin(appState: AppState) async {
isBiometricLoading = true
errorMessage = nil
do {
let creds = try KeychainService.loadCredentials()
email = creds.email
password = creds.password
serverHost = creds.host
serverPort = String(creds.port)
isBiometricLoading = false
await login(appState: appState)
// If login failed, reset to defaults so the form isn't stuck on stale values
if !appState.isLoggedIn {
serverHost = Constants.defaultHost
serverPort = String(Constants.defaultPort)
password = ""
KeychainService.deleteCredentials()
hasSavedCredentials = false
}
} catch KeychainService.KeychainError.biometricFailed {
isBiometricLoading = false
// User cancelled just let them type manually
} catch {
isBiometricLoading = false
errorMessage = error.localizedDescription
}
}
func register(appState: AppState) async {
guard !email.isEmpty, !password.isEmpty, !username.isEmpty else {
errorMessage = "All fields are required"
return
}
guard password == confirmPassword else {
errorMessage = "Passwords don't match"
return
}
isLoading = true
errorMessage = nil
#if DEBUG
print("DEBUG register: connecting to \(serverHost):\(serverPort)")
#endif
if await !appState.chatClient.isConnected {
do {
let port = UInt16(serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(host: serverHost, port: port)
#if DEBUG
print("DEBUG register: connected successfully")
#endif
} catch {
isLoading = false
#if DEBUG
print("DEBUG register: connection failed - \(error)")
#endif
errorMessage = "Connection failed: \(error.localizedDescription)"
return
}
}
#if DEBUG
print("DEBUG register: calling chatClient.register")
#endif
let (success, message) = await appState.chatClient.register(username: username, password: password, email: email)
isLoading = false
#if DEBUG
print("DEBUG AuthViewModel: register returned success=\(success), message=\(message)")
#endif
if success {
registrationMessage = message
showConfirmation = true
#if DEBUG
print("DEBUG AuthViewModel: showConfirmation set to true")
#endif
} else {
errorMessage = message
#if DEBUG
print("DEBUG AuthViewModel: errorMessage set to \(message)")
#endif
}
}
func confirmRegistration(appState: AppState) async {
guard !confirmationCode.isEmpty else {
errorMessage = "Enter the confirmation code"
return
}
isLoading = true
errorMessage = nil
let (success, message) = await appState.chatClient.confirmRegistration(
email: email, username: username, code: confirmationCode
)
isLoading = false
if success {
registrationMessage = message
// Auto-login after registration
await login(appState: appState)
} else {
errorMessage = message
}
}
}

View File

@@ -0,0 +1,356 @@
import Foundation
import SwiftUI
@Observable
final class ChatViewModel {
var messages: [Message] = []
var isLoading = false
var isSending = false
var errorMessage: String?
var searchQuery = ""
var searchResults: [String] = [] // message IDs matching search
var currentSearchIndex = 0
private var notificationTask: Task<Void, Never>?
func loadMessages(convId: String, chatClient: ChatClient) async {
let email = await chatClient.email
let cacheKey = await chatClient.cacheKey
// 1. Load from cache
let cachedDicts = MessageCache.load(email: email, convId: convId, cacheKey: cacheKey)
let cached = cachedDicts?.compactMap { Message.fromCacheDict($0) } ?? []
if !cached.isEmpty {
// Cache hit show immediately, no spinner
messages = cached.sorted { $0.createdAt < $1.createdAt }
} else {
// No cache show spinner (first open)
isLoading = true
}
// 2. Determine after_ts from newest cached message
let newestCached = messages.last
// 3. Fetch from server
let serverMessages: [Message]
if let newest = newestCached {
let afterTs = DateParsing.format(newest.createdAt)
#if DEBUG
print("DEBUG getMessages after_ts=\(afterTs)")
#endif
serverMessages = await chatClient.getMessages(convId: convId, limit: 50, afterTs: afterTs)
} else {
serverMessages = await chatClient.getMessages(convId: convId, limit: 50)
}
// 4. Merge
if newestCached != nil {
// Incremental: dedup by ID, append new, sort
let existingIds = Set(messages.map(\.id))
let newMessages = serverMessages.filter { !existingIds.contains($0.id) }
if !newMessages.isEmpty {
messages.append(contentsOf: newMessages)
messages.sort { $0.createdAt < $1.createdAt }
}
} else {
// Full fetch: replace
messages = serverMessages
}
// 5. Sync deleted (only for incremental)
if let newest = newestCached {
let afterTs = DateParsing.format(newest.createdAt)
#if DEBUG
print("DEBUG get_deleted_since since_ts=\(afterTs)")
#endif
let deletedIds = await chatClient.getDeletedSince(convId: convId, sinceTs: afterTs)
if !deletedIds.isEmpty {
messages.removeAll { deletedIds.contains($0.id) }
}
}
// 6. Loading done
isLoading = false
// 7. Save to cache
await saveCache(convId: convId, chatClient: chatClient)
// 8. Mark entire conversation as read (server-side bulk mark)
// This handles messages not in cache (e.g. failed to decrypt or never fetched)
await chatClient.markConversationRead(convId: convId)
// Update local readBy for cached messages so cache reflects read state
let currentUserId = await chatClient.userId ?? ""
var anyUpdated = false
for i in messages.indices {
if !messages[i].isMine(currentUserId: currentUserId) && !messages[i].readBy.contains(currentUserId) {
messages[i].readBy.insert(currentUserId)
anyUpdated = true
}
}
if anyUpdated {
await saveCache(convId: convId, chatClient: chatClient)
}
}
func loadOlderMessages(convId: String, chatClient: ChatClient) async {
let older = await chatClient.getMessages(convId: convId, limit: 50, offset: messages.count)
messages.insert(contentsOf: older, at: 0)
await saveCache(convId: convId, chatClient: chatClient)
}
func sendMessage(convId: String, text: String, members: [ConversationMember],
chatClient: ChatClient, replyTo: String? = nil) async {
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isSending = true
errorMessage = nil
let (success, msg, sentMessage) = await chatClient.sendMessage(
convId: convId, text: text, members: members, replyTo: replyTo
)
isSending = false
if !success {
errorMessage = msg
} else if let sentMessage = sentMessage {
// Append locally don't reload from server (ratchet keys are one-time)
if !messages.contains(where: { $0.id == sentMessage.id }) {
messages.append(sentMessage)
}
await saveCache(convId: convId, chatClient: chatClient)
}
}
func deleteMessage(messageId: String, convId: String, chatClient: ChatClient) async {
let success = await chatClient.deleteMessage(messageId: messageId, convId: convId)
if success {
messages.removeAll { $0.id == messageId }
await saveCache(convId: convId, chatClient: chatClient)
}
}
func saveCache(convId: String, chatClient: ChatClient) async {
let email = await chatClient.email
let cacheKey = await chatClient.cacheKey
let dicts = messages.map { $0.toCacheDict() }
try? MessageCache.save(email: email, convId: convId, messages: dicts, cacheKey: cacheKey)
}
func search(query: String) {
searchQuery = query
if query.isEmpty {
searchResults = []
currentSearchIndex = 0
return
}
let lower = query.lowercased()
searchResults = messages.filter { $0.text?.lowercased().contains(lower) == true }.map(\.id)
currentSearchIndex = searchResults.isEmpty ? 0 : searchResults.count - 1
}
func nextSearchResult() {
guard !searchResults.isEmpty else { return }
currentSearchIndex = (currentSearchIndex + 1) % searchResults.count
}
func prevSearchResult() {
guard !searchResults.isEmpty else { return }
currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count
}
func startNotificationListener(convId: String, chatClient: ChatClient) {
notificationTask?.cancel()
notificationTask = Task {
for await notification in await chatClient.makeNotificationStream() {
await handleNotification(notification, convId: convId, chatClient: chatClient)
}
}
}
@MainActor
private func handleNotification(_ notification: ChatNotification, convId: String, chatClient: ChatClient) {
switch notification {
case .newMessage(let data):
if data["conversation_id"] as? String == convId {
Task {
if let message = await chatClient.decryptNotification(data) {
await MainActor.run {
// Deduplicate sent messages are already appended locally
if !messages.contains(where: { $0.id == message.id }) {
messages.append(message)
}
}
await saveCache(convId: convId, chatClient: chatClient)
// Only mark as read if from someone else
let myId = await chatClient.userId ?? ""
if message.senderId != myId {
await chatClient.markRead(convId: convId, messageIds: [message.id])
}
await chatClient.flushSelfEncrypt()
}
}
}
case .messageDeleted(let data):
if let msgId = data["message_id"] as? String {
messages.removeAll { $0.id == msgId }
Task {
await saveCache(convId: convId, chatClient: chatClient)
}
}
case .messagesRead(let data):
if let readUserId = data["user_id"] as? String,
let msgIds = data["message_ids"] as? [String] {
for i in messages.indices {
if msgIds.contains(messages[i].id) {
messages[i].readBy.insert(readUserId)
}
}
}
case .messageReacted(let data):
if let msgId = data["message_id"] as? String,
let reactUserId = data["user_id"] as? String,
let reaction = data["reaction"] as? String,
let action = data["action"] as? String,
let idx = messages.firstIndex(where: { $0.id == msgId }) {
if action == "add" {
let newReaction = MessageReaction(userId: reactUserId, reaction: reaction, createdAt: Date())
if !messages[idx].reactions.contains(where: { $0.userId == reactUserId && $0.reaction == reaction }) {
messages[idx].reactions.append(newReaction)
}
} else {
messages[idx].reactions.removeAll { $0.userId == reactUserId && $0.reaction == reaction }
}
Task { await saveCache(convId: convId, chatClient: chatClient) }
}
case .messagePinned(let data):
if let msgId = data["message_id"] as? String,
let pinUserId = data["user_id"] as? String,
let idx = messages.firstIndex(where: { $0.id == msgId }) {
messages[idx].pinnedAt = Date()
messages[idx].pinnedBy = pinUserId
Task { await saveCache(convId: convId, chatClient: chatClient) }
}
case .messageUnpinned(let data):
if let msgId = data["message_id"] as? String,
let idx = messages.firstIndex(where: { $0.id == msgId }) {
messages[idx].pinnedAt = nil
messages[idx].pinnedBy = nil
Task { await saveCache(convId: convId, chatClient: chatClient) }
}
case .messageDelivered(let data):
// Delivery receipt message was successfully received by recipient
if let msgId = data["message_id"] as? String,
let idx = messages.firstIndex(where: { $0.id == msgId }) {
messages[idx].readBy.insert("__delivered__")
Task { await saveCache(convId: convId, chatClient: chatClient) }
}
default:
break
}
}
func reactToMessage(messageId: String, convId: String, reaction: String,
currentUserId: String, chatClient: ChatClient) async {
guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return }
let existingReaction = messages[idx].reactions.first { $0.userId == currentUserId }
let hasSameReaction = existingReaction?.reaction == reaction
let savedReactions = messages[idx].reactions
// Optimistic update
if hasSameReaction {
// Tapping same emoji remove it
messages[idx].reactions.removeAll { $0.userId == currentUserId }
} else {
// Remove any previous reaction from this user, then add new one
messages[idx].reactions.removeAll { $0.userId == currentUserId }
messages[idx].reactions.append(MessageReaction(userId: currentUserId, reaction: reaction, createdAt: Date()))
}
// If user had a different reaction, remove it on server first
if let old = existingReaction, old.reaction != reaction {
let _ = await chatClient.reactMessage(messageId: messageId, conversationId: convId,
reaction: old.reaction, action: "remove")
}
// Add or remove the target reaction on server
let action = hasSameReaction ? "remove" : "add"
let success = await chatClient.reactMessage(messageId: messageId, conversationId: convId,
reaction: reaction, action: action)
if !success {
// Revert on failure
messages[idx].reactions = savedReactions
}
await saveCache(convId: convId, chatClient: chatClient)
}
func pinMessage(messageId: String, convId: String, pin: Bool,
chatClient: ChatClient) async {
guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return }
// Optimistic update
if pin {
messages[idx].pinnedAt = Date()
messages[idx].pinnedBy = await chatClient.userId
} else {
messages[idx].pinnedAt = nil
messages[idx].pinnedBy = nil
}
let success = await chatClient.pinMessage(messageId: messageId, conversationId: convId,
action: pin ? "pin" : "unpin")
if !success {
// Revert on failure
if pin {
messages[idx].pinnedAt = nil
messages[idx].pinnedBy = nil
}
}
await saveCache(convId: convId, chatClient: chatClient)
}
// MARK: - Forward Message
func forwardMessage(message: Message, targetConvId: String,
targetMembers: [ConversationMember], chatClient: ChatClient) async -> Bool {
var originalMsg: [String: Any] = [
"text": message.text ?? "",
"sender": message.senderUsername,
"conversation_id": message.conversationId,
"message_id": message.id,
]
if let file = message.file {
originalMsg["file"] = [
"file_id": file.fileId,
"aes_key": file.aesKey,
"iv": file.iv,
"filename": file.filename,
"size": file.size,
"mime_type": file.mimeType,
] as [String: Any]
}
if let image = message.image {
var imgDict: [String: Any] = [
"file_id": image.fileId,
"aes_key": image.aesKey,
"iv": image.iv,
"filename": image.filename,
"size": image.size,
]
if let thumb = image.thumbnail { imgDict["thumbnail"] = thumb }
originalMsg["image"] = imgDict
}
let (success, _, _) = await chatClient.forwardMessage(
targetConvId: targetConvId, originalMsg: originalMsg,
targetMembers: targetMembers
)
return success
}
func stop() {
notificationTask?.cancel()
notificationTask = nil
}
}

View File

@@ -0,0 +1,246 @@
import Foundation
import SwiftUI
@Observable
final class ConversationListVM {
var conversations: [Conversation] = []
var invitations: [Invitation] = []
var onlineUsers: Set<String> = []
var unreadCounts: [String: Int] = [:]
var favorites: Set<String> = []
var avatarCache: [String: Data] = [:] // convId -> avatar image data
var isLoading = false
private var notificationTask: Task<Void, Never>?
private var avatarTask: Task<Void, Never>?
private var refreshTask: Task<Void, Never>?
private var localKey: Data?
private var email: String = ""
private var lastRefreshTime: Date = .distantPast
func load(chatClient: ChatClient, email: String) async {
isLoading = true
self.email = email
// Load favorites from disk (encrypted with localKey)
localKey = await chatClient.localKey
favorites = KeyStorage.loadFavorites(email: email, localKey: localKey)
let currentUserId = await chatClient.userId ?? ""
// Load cached conversations immediately (show while fetching from server)
if let cached = MessageCache.loadConversations(email: email, cacheKey: localKey) {
conversations = sortConversations(cached, currentUserId: currentUserId)
for conv in conversations where conv.unreadCount > 0 {
unreadCounts[conv.id] = conv.unreadCount
}
}
// Load cached avatars from disk
let diskAvatars = MessageCache.loadAllAvatars(email: email, cacheKey: localKey)
if !diskAvatars.isEmpty {
avatarCache = diskAvatars
}
// Fetch conversations from server
let convs = await chatClient.listConversations()
if !convs.isEmpty {
// Sync unread counts from server (authoritative source)
for conv in convs {
unreadCounts[conv.id] = conv.unreadCount
}
conversations = sortConversations(convs, currentUserId: currentUserId)
// Save to cache
MessageCache.saveConversations(email: email, conversations: convs, cacheKey: localKey)
}
// Fetch invitations
invitations = await chatClient.listInvitations()
isLoading = false
lastRefreshTime = Date()
// Start notification listener
startNotificationListener(chatClient: chatClient, email: email)
// Read initial online users stored in ChatClient
// (online_users notification arrives during login before any subscriber exists)
onlineUsers = await chatClient.onlineUserIds
// Load avatars in background (non-blocking)
avatarTask?.cancel()
avatarTask = Task { await loadAvatars(chatClient: chatClient, currentUserId: currentUserId) }
}
func refresh(chatClient: ChatClient) async {
// Debounce: skip if refreshed < 2s ago
guard Date().timeIntervalSince(lastRefreshTime) > 2 else {
#if DEBUG
print("DEBUG ConversationListVM: refresh debounced")
#endif
return
}
lastRefreshTime = Date()
let currentUserId = await chatClient.userId ?? ""
let convs = await chatClient.listConversations()
if !convs.isEmpty {
// Sync unread counts from server (authoritative source)
for conv in convs {
unreadCounts[conv.id] = conv.unreadCount
}
conversations = sortConversations(convs, currentUserId: currentUserId)
// Save to cache
MessageCache.saveConversations(email: email, conversations: convs, cacheKey: localKey)
}
invitations = await chatClient.listInvitations()
// Refresh avatars in background
avatarTask?.cancel()
avatarTask = Task { await loadAvatars(chatClient: chatClient, currentUserId: currentUserId) }
}
func toggleFavorite(convId: String, email: String) {
if favorites.contains(convId) {
favorites.remove(convId)
} else {
favorites.insert(convId)
}
try? KeyStorage.saveFavorites(email: email, favorites: favorites, localKey: localKey)
// Re-sort
let userId = conversations.first?.createdBy ?? ""
conversations = sortConversations(conversations, currentUserId: userId)
}
func forceRefresh(chatClient: ChatClient) async {
lastRefreshTime = .distantPast
await refresh(chatClient: chatClient)
}
func updateAvatar(convId: String, data: Data) {
avatarCache[convId] = data
// Persist to disk so it survives load() re-reads
MessageCache.saveAvatar(email: email, key: convId, data: data, cacheKey: localKey)
}
func markConversationRead(convId: String) {
unreadCounts[convId] = 0
}
func incrementUnread(convId: String) {
unreadCounts[convId, default: 0] += 1
}
private func sortConversations(_ convs: [Conversation], currentUserId: String) -> [Conversation] {
var result = convs.map { conv -> Conversation in
var c = conv
c.isFavorite = favorites.contains(conv.id)
c.unreadCount = unreadCounts[conv.id] ?? conv.unreadCount
return c
}
result.sort { a, b in
// Favorites first
if a.isFavorite != b.isFavorite { return a.isFavorite }
// Online DMs next
let aOnline = a.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
let bOnline = b.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
if aOnline != bOnline { return aOnline }
// Alphabetical
return a.displayName(currentUserId: currentUserId).lowercased() < b.displayName(currentUserId: currentUserId).lowercased()
}
return result
}
private func startNotificationListener(chatClient: ChatClient, email: String) {
notificationTask?.cancel()
notificationTask = Task {
for await notification in await chatClient.makeNotificationStream() {
await handleNotification(notification, chatClient: chatClient, email: email)
}
}
}
@MainActor
private func handleNotification(_ notification: ChatNotification, chatClient: ChatClient, email: String) {
switch notification {
case .newMessage(let data):
if let convId = data["conversation_id"] as? String {
incrementUnread(convId: convId)
}
case .onlineUsers(let userIds):
onlineUsers = Set(userIds)
case .userOnline(let userId):
onlineUsers.insert(userId)
case .userOffline(let userId):
onlineUsers.remove(userId)
case .conversationCreated, .memberAdded, .memberRemoved, .conversationRenamed, .conversationDeleted:
refreshTask?.cancel()
refreshTask = Task { await refresh(chatClient: chatClient) }
case .groupInvitation:
Task { invitations = await chatClient.listInvitations() }
case .reconnected:
#if DEBUG
print("DEBUG ConversationListVM: reconnected — refreshing")
#endif
refreshTask?.cancel()
refreshTask = Task { await refresh(chatClient: chatClient) }
case .connectionStateChanged(let connected):
if !connected {
#if DEBUG
print("DEBUG ConversationListVM: disconnected")
#endif
}
default:
break
}
}
private func loadAvatars(chatClient: ChatClient, currentUserId: String) async {
await withTaskGroup(of: (String, Data?).self) { group in
for conv in conversations {
let convId = conv.id
// Skip if already cached in memory
if avatarCache[convId] != nil { continue }
if conv.isGroup {
// Only fetch if group has an avatar file
if conv.avatarFile != nil {
group.addTask {
let data = await chatClient.getGroupAvatar(convId: convId)
return (convId, data)
}
}
} else {
// DM: fetch partner's avatar
if let partnerId = conv.dmPartnerId(currentUserId: currentUserId) {
group.addTask {
let data = await chatClient.getAvatar(userId: partnerId)
return (convId, data)
}
}
}
}
let emailCapture = email
let keyCapture = localKey
for await (convId, data) in group {
if let data = data {
avatarCache[convId] = data
// Save to disk cache
MessageCache.saveAvatar(email: emailCapture, key: convId, data: data, cacheKey: keyCapture)
}
}
}
}
func stop() {
notificationTask?.cancel()
notificationTask = nil
avatarTask?.cancel()
avatarTask = nil
refreshTask?.cancel()
refreshTask = nil
}
}

View File

@@ -0,0 +1,98 @@
import Foundation
import SwiftUI
@Observable
final class ProfileViewModel {
var profile: UserProfile?
var avatarData: Data?
var isLoading = false
var isSaving = false
var errorMessage: String?
// Editable fields
var phone = ""
var phoneVisible = false
var location = ""
var locationVisible = false
func loadProfile(userId: String? = nil, chatClient: ChatClient) async {
isLoading = true
profile = await chatClient.getProfile(userId: userId)
isLoading = false
if let p = profile {
phone = p.phone ?? ""
phoneVisible = p.phoneVisible
location = p.location ?? ""
locationVisible = p.locationVisible
}
// Load avatar
let clientUserId = await chatClient.userId
let uid = userId ?? clientUserId ?? ""
if !uid.isEmpty {
avatarData = await chatClient.getAvatar(userId: uid)
}
}
@discardableResult
func saveProfile(chatClient: ChatClient) async -> Bool {
isSaving = true
errorMessage = nil
let success = await chatClient.updateProfile(
phone: phone.isEmpty ? nil : phone,
phoneVisible: phoneVisible,
location: location.isEmpty ? nil : location,
locationVisible: locationVisible
)
isSaving = false
if !success {
errorMessage = "Failed to update profile"
}
return success
}
func uploadAvatar(imageData: Data, chatClient: ChatClient) async {
isSaving = true
errorMessage = nil
let (success, msg) = await chatClient.updateAvatar(imageData: imageData)
isSaving = false
if success {
// Reload avatar from server (it was resized/compressed)
let clientUserId = await chatClient.userId ?? ""
avatarData = await chatClient.getAvatar(userId: clientUserId)
} else {
errorMessage = msg.isEmpty ? "Failed to upload avatar" : msg
}
}
// MARK: - Username Change
func changeUsername(newUsername: String, chatClient: ChatClient) async -> Bool {
isSaving = true
errorMessage = nil
let (success, msg) = await chatClient.changeUsername(newUsername: newUsername)
isSaving = false
if !success {
errorMessage = msg
}
return success
}
// MARK: - Password Change
func changePassword(oldPassword: String, newPassword: String, chatClient: ChatClient) async -> Bool {
isSaving = true
errorMessage = nil
let (success, msg) = await chatClient.changePassword(oldPassword: oldPassword, newPassword: newPassword)
isSaving = false
if !success {
errorMessage = msg
}
return success
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
import SwiftUI
@Observable
final class VerificationVM {
var safetyNumber: String?
var myFingerprint: String?
var peerFingerprint: String?
var verificationStatus: String = "unverified" // "verified", "trusted", "unverified"
var qrCodeData: Data?
var scanResult: String?
var scanSuccess: Bool?
var isLoading = false
var errorMessage: String?
func loadVerification(peerUserId: String, chatClient: ChatClient) async {
isLoading = true
// Ensure peer's identity key is fetched (needed for safety number & verification)
_ = await chatClient.getPeerIdentityKey(userId: peerUserId)
// Get safety number
safetyNumber = await chatClient.getSafetyNumber(peerUserId: peerUserId)
// Get fingerprints
myFingerprint = await chatClient.getMyFingerprint()
peerFingerprint = await chatClient.getPeerFingerprint(peerUserId: peerUserId)
// Get verification status
verificationStatus = await chatClient.getVerificationStatus(userId: peerUserId)
// Get QR code data for display
qrCodeData = await chatClient.getVerificationQRData()
isLoading = false
}
func verifyContact(peerUserId: String, chatClient: ChatClient) async {
guard let peerIK = await chatClient.getPeerIdentityKey(userId: peerUserId) else {
errorMessage = "No identity key on record for this user."
return
}
await chatClient.verifyContact(userId: peerUserId, identityKey: peerIK, method: "manual")
verificationStatus = "verified"
}
func unverifyContact(peerUserId: String, chatClient: ChatClient) async {
await chatClient.unverifyContact(userId: peerUserId)
verificationStatus = "trusted"
}
func verifyQRCode(data: Data, chatClient: ChatClient) async {
let (success, _, message) = await chatClient.verifyQRCode(qrData: data)
scanSuccess = success
scanResult = message
if success {
verificationStatus = "verified"
}
}
}

View File

@@ -0,0 +1,82 @@
import SwiftUI
struct AuthorizeDeviceView: View {
var appState: AppState
@State private var code = ""
@State private var isAuthorizing = false
@State private var statusMessage: String?
@State private var isError = false
@State private var isDone = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Image(systemName: "iphone.badge.checkmark")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Authorize New Device")
.font(.title2.bold())
Text("Enter the 8-digit pairing code shown on the new device.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TextField("Pairing Code", text: $code)
.font(.system(size: 24, weight: .bold, design: .monospaced))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
Button("Authorize") {
Task { await authorize() }
}
.buttonStyle(.borderedProminent)
.disabled(code.count < 8 || isAuthorizing || isDone)
if isAuthorizing {
ProgressView("Preparing history & sending keys...")
}
if let status = statusMessage {
Text(status)
.font(.caption)
.foregroundStyle(isError ? .red : .green)
.multilineTextAlignment(.center)
}
if isDone {
Button("Done") { dismiss() }
.buttonStyle(.bordered)
}
}
.padding(32)
}
.navigationTitle("Authorize Device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
}
private func authorize() async {
isAuthorizing = true
isError = false
statusMessage = nil
let (success, msg) = await appState.chatClient.authorizeDevice(code: code)
isAuthorizing = false
statusMessage = msg
isError = !success
if success {
isDone = true
}
}
}

View File

@@ -0,0 +1,179 @@
import SwiftUI
struct LoginView: View {
@Bindable var viewModel: AuthViewModel
var appState: AppState
@State private var showPairing = false
@State private var didAttemptBiometric = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
.padding(.top, 40)
Text("Encrypted Chat")
.font(.largeTitle.bold())
Text("End-to-end encrypted messaging")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(spacing: 16) {
// Server config
DisclosureGroup("Server") {
TextField("Host", text: $viewModel.serverHost)
.textContentType(.URL)
.autocapitalization(.none)
TextField("Port", text: $viewModel.serverPort)
.keyboardType(.numberPad)
}
.padding(.horizontal)
if viewModel.mode == .register {
TextField("Username", text: $viewModel.username)
.textContentType(.username)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
}
TextField("Email", text: $viewModel.email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password", text: $viewModel.password)
.textContentType(viewModel.mode == .login ? .password : .oneTimeCode)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
if viewModel.mode == .register {
SecureField("Confirm Password", text: $viewModel.confirmPassword)
.textContentType(.oneTimeCode)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
}
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.multilineTextAlignment(.center)
}
Button(action: {
Task {
if viewModel.mode == .login {
await viewModel.login(appState: appState)
} else {
await viewModel.register(appState: appState)
}
}
}) {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text(viewModel.mode == .login ? "Login" : "Register")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
Button(viewModel.mode == .login ? "Don't have an account? Register" : "Already have an account? Login") {
viewModel.mode = viewModel.mode == .login ? .register : .login
viewModel.errorMessage = nil
}
.font(.caption)
if viewModel.hasSavedCredentials && viewModel.mode == .login {
Divider()
.padding(.vertical, 4)
Button {
Task { await viewModel.biometricLogin(appState: appState) }
} label: {
if viewModel.isBiometricLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Sign in with Face ID", systemImage: "faceid")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.bordered)
.disabled(viewModel.isLoading || viewModel.isBiometricLoading)
}
Divider()
.padding(.vertical, 4)
Button("Pair from existing device") {
showPairing = true
}
.font(.caption)
}
.padding(.horizontal, 32)
}
}
.task {
viewModel.checkSavedCredentials()
if viewModel.hasSavedCredentials && !didAttemptBiometric {
didAttemptBiometric = true
await viewModel.biometricLogin(appState: appState)
}
}
.sheet(isPresented: $viewModel.showConfirmation) {
ConfirmationSheet(viewModel: viewModel, appState: appState)
}
.sheet(isPresented: $showPairing) {
PairingView(appState: appState, authViewModel: viewModel)
}
}
}
}
struct ConfirmationSheet: View {
@Bindable var viewModel: AuthViewModel
var appState: AppState
var body: some View {
VStack(spacing: 20) {
Text("Confirm Registration")
.font(.title2.bold())
if let msg = viewModel.registrationMessage {
Text(msg)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
TextField("Confirmation Code", text: $viewModel.confirmationCode)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
Button("Confirm") {
Task {
await viewModel.confirmRegistration(appState: appState)
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading)
}
.padding(32)
}
}

View File

@@ -0,0 +1,175 @@
import SwiftUI
struct PairingView: View {
var appState: AppState
@Bindable var authViewModel: AuthViewModel
@State private var email = ""
@State private var password = ""
@State private var pairingCode: String?
@State private var isStarting = false
@State private var isWaiting = false
@State private var statusMessage: String?
@State private var isError = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Image(systemName: "iphone.and.arrow.forward")
.font(.system(size: 48))
.foregroundStyle(.blue)
Text("Device Pairing")
.font(.title2.bold())
Text("Transfer your keys from an existing device to this one.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if pairingCode == nil {
// Phase 1: Enter email and start pairing
VStack(spacing: 16) {
// Server config
DisclosureGroup("Server") {
TextField("Host", text: $authViewModel.serverHost)
.textContentType(.URL)
.autocapitalization(.none)
TextField("Port", text: $authViewModel.serverPort)
.keyboardType(.numberPad)
}
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password (for key encryption)", text: $password)
.textContentType(.password)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textFieldStyle(.roundedBorder)
Button("Start Pairing") {
Task { await startPairing() }
}
.buttonStyle(.borderedProminent)
.disabled(email.isEmpty || password.isEmpty || isStarting)
if isStarting {
ProgressView("Connecting...")
}
}
} else {
// Phase 2: Show code and wait for authorization
VStack(spacing: 16) {
Text("Pairing Code")
.font(.headline)
Text(pairingCode!)
.font(.system(size: 36, weight: .bold, design: .monospaced))
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
Text("Enter this code on your already logged-in device\nto authorize this device.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if isWaiting {
ProgressView("Waiting for authorization...")
.padding()
}
}
}
if let status = statusMessage {
Text(status)
.font(.caption)
.foregroundStyle(isError ? .red : .green)
.multilineTextAlignment(.center)
}
}
.padding(32)
}
.navigationTitle("Pair Device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
}
private func startPairing() async {
isStarting = true
isError = false
statusMessage = nil
// Connect to server
if await !appState.chatClient.isConnected {
do {
let port = UInt16(authViewModel.serverPort) ?? Constants.defaultPort
try await appState.chatClient.connect(
host: authViewModel.serverHost, port: port
)
} catch {
isStarting = false
statusMessage = "Connection failed: \(error.localizedDescription)"
isError = true
return
}
}
let (success, codeOrMsg) = await appState.chatClient.pairingStart(email: email)
isStarting = false
if success {
pairingCode = codeOrMsg
// Start waiting for authorization
isWaiting = true
Task { await waitForAuthorization() }
} else {
statusMessage = codeOrMsg
isError = true
}
}
private func waitForAuthorization() async {
let (success, msg) = await appState.chatClient.pairingWait(
code: pairingCode!, email: email, password: password
)
isWaiting = false
if success {
statusMessage = msg
isError = false
// Auto-login
let (loginOk, loginMsg) = await appState.chatClient.login(email: email, password: password)
if loginOk {
appState.email = email
appState.isLoggedIn = true
appState.connectionStatus = .connected
appState.startConnectionMonitor()
if let userId = await appState.chatClient.userId {
appState.currentUser = User(
id: userId,
username: await appState.chatClient.username,
email: email
)
}
dismiss()
} else {
statusMessage = "Keys imported but login failed: \(loginMsg)"
isError = true
}
} else {
statusMessage = msg
isError = true
}
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Registration is handled within LoginView via mode toggle.
// This file exists for potential future separation.

View File

@@ -0,0 +1,382 @@
import SwiftUI
struct ChatView: View {
@State private var conversation: Conversation
var appState: AppState
var conversationListVM: ConversationListVM?
init(conversation: Conversation, appState: AppState, conversationListVM: ConversationListVM? = nil) {
self._conversation = State(initialValue: conversation)
self.appState = appState
self.conversationListVM = conversationListVM
}
@State private var viewModel = ChatViewModel()
@State private var inputText = ""
@State private var replyTo: Message?
@State private var showGroupInfo = false
@State private var showDMInfo = false
@State private var showSearch = false
@State private var showDeleteConfirm = false
@State private var showError = false
@State private var memberListenerTask: Task<Void, Never>?
@State private var forwardingMessage: Message?
@State private var showForwardPicker = false
@State private var showPinnedMessages = false
@State private var scrollTarget: String?
@State private var showVerification = false
@State private var verificationStatus: String = "unverified"
private var currentUserId: String {
appState.currentUser?.id ?? ""
}
private var isPartnerOnline: Bool {
guard !conversation.isGroup,
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId),
let listVM = conversationListVM else {
return false
}
return listVM.onlineUsers.contains(partnerId)
}
var body: some View {
VStack(spacing: 0) {
searchBar
messagesScrollView
replyPreview
inputView
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task { await appState.chatClient.deleteConversation(convId: conversation.id) }
}
} message: {
Text(conversation.isGroup
? "This will remove all members and delete the conversation."
: "This will remove you from the conversation.")
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "Unknown error")
}
.sheet(isPresented: $showGroupInfo) {
GroupInfoView(conversation: $conversation, appState: appState, conversationListVM: conversationListVM)
}
.sheet(isPresented: $showDMInfo) {
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
ProfileView(appState: appState, isOwnProfile: false, userId: partnerId)
}
}
.sheet(isPresented: $showForwardPicker) {
if let msg = forwardingMessage {
ForwardPickerView(message: msg, appState: appState)
}
}
.sheet(isPresented: $showVerification) {
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
NavigationStack {
SafetyNumberView(
peerUserId: partnerId,
peerUsername: conversation.displayName(currentUserId: currentUserId),
chatClient: appState.chatClient
)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { showVerification = false }
}
}
}
}
}
.sheet(isPresented: $showPinnedMessages) {
PinnedMessagesView(
messages: viewModel.messages.filter { $0.pinnedAt != nil },
onScrollTo: { scrollTarget = $0 }
)
}
.task {
// Use already-loaded data from conversation list (avoid redundant list_conversations call)
if let updated = conversationListVM?.conversations.first(where: { $0.id == conversation.id }) {
conversation = updated
}
conversationListVM?.markConversationRead(convId: conversation.id)
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
// Load verification status for DM partner
if !conversation.isGroup,
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
verificationStatus = await appState.chatClient.getVerificationStatus(userId: partnerId)
}
memberListenerTask = Task {
for await notification in await appState.chatClient.makeNotificationStream() {
switch notification {
case .memberAdded, .memberRemoved, .conversationRenamed:
let refreshed = await appState.chatClient.listConversations()
if let updated = refreshed.first(where: { $0.id == conversation.id }) {
await MainActor.run { conversation = updated }
}
default:
break
}
}
}
}
.onDisappear {
viewModel.stop()
memberListenerTask?.cancel()
memberListenerTask = nil
}
}
// MARK: - Search Bar
@ViewBuilder
private var searchBar: some View {
if showSearch {
SearchOverlayView(
query: $viewModel.searchQuery,
matchCount: viewModel.searchResults.count,
currentIndex: viewModel.currentSearchIndex,
onSearch: { viewModel.search(query: $0) },
onNext: { viewModel.nextSearchResult() },
onPrev: { viewModel.prevSearchResult() },
onClose: { showSearch = false; viewModel.search(query: "") }
)
}
}
// MARK: - Messages
private var messagesScrollView: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
if viewModel.messages.count >= 50 {
Button("Load older messages") {
Task {
await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient)
}
}
.font(.caption)
.padding()
}
ForEach(viewModel.messages) { message in
messageBubble(for: message)
.id(message.id)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.onChange(of: viewModel.messages.count) {
if let lastId = viewModel.messages.last?.id {
withAnimation { proxy.scrollTo(lastId, anchor: .bottom) }
}
}
.onChange(of: scrollTarget) {
if let target = scrollTarget {
withAnimation { proxy.scrollTo(target, anchor: .center) }
scrollTarget = nil
}
}
}
}
private func messageBubble(for message: Message) -> some View {
let isCurrentSearch = viewModel.searchResults.indices.contains(viewModel.currentSearchIndex)
&& viewModel.searchResults[viewModel.currentSearchIndex] == message.id
return MessageBubbleView(
message: message,
isMine: message.isMine(currentUserId: currentUserId),
isGroup: conversation.isGroup,
isHighlighted: viewModel.searchResults.contains(message.id),
isCurrentSearchResult: isCurrentSearch,
chatClient: appState.chatClient,
currentUserId: currentUserId,
onReply: { replyTo = message },
onReact: { reaction in
Task {
await viewModel.reactToMessage(
messageId: message.id, convId: conversation.id,
reaction: reaction, currentUserId: currentUserId,
chatClient: appState.chatClient
)
}
},
onForward: {
forwardingMessage = message
showForwardPicker = true
},
onPin: { pin in
Task {
await viewModel.pinMessage(
messageId: message.id, convId: conversation.id,
pin: pin, chatClient: appState.chatClient
)
}
},
onDelete: {
Task {
await viewModel.deleteMessage(
messageId: message.id, convId: conversation.id,
chatClient: appState.chatClient
)
}
}
)
}
// MARK: - Reply Preview
@ViewBuilder
private var replyPreview: some View {
if let reply = replyTo {
HStack {
Rectangle()
.fill(.blue)
.frame(width: 3)
VStack(alignment: .leading) {
Text(reply.senderUsername)
.font(.caption.bold())
Text(reply.text ?? "")
.font(.caption)
.lineLimit(1)
}
Spacer()
Button(action: { replyTo = nil }) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
}
// MARK: - Input
private var inputView: some View {
MessageInputView(
text: $inputText,
isSending: viewModel.isSending,
onSend: {
Task {
let text = inputText
inputText = ""
let reply = replyTo?.id
replyTo = nil
await viewModel.sendMessage(
convId: conversation.id, text: text,
members: conversation.members,
chatClient: appState.chatClient, replyTo: reply
)
}
},
onImageSelected: { imageData in
Task {
viewModel.isSending = true
let (success, msg, sentMessage) = await appState.chatClient.sendImage(
convId: conversation.id, imageData: imageData,
members: conversation.members
)
viewModel.isSending = false
if success, let sentMessage {
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
viewModel.messages.append(sentMessage)
}
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
} else if !success {
viewModel.errorMessage = msg
showError = true
}
}
},
onFileSelected: { fileData, filename, mimeType in
Task {
viewModel.isSending = true
let (success, msg, sentMessage) = await appState.chatClient.sendFile(
convId: conversation.id, fileData: fileData,
filename: filename, mimeType: mimeType,
members: conversation.members
)
viewModel.isSending = false
if success, let sentMessage {
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
viewModel.messages.append(sentMessage)
}
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
} else if !success {
viewModel.errorMessage = msg
showError = true
}
}
},
members: conversation.members
)
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .principal) {
HStack(spacing: 8) {
CircularAvatarView(
name: conversation.displayName(currentUserId: currentUserId),
imageData: conversationListVM?.avatarCache[conversation.id],
size: 28,
isGroup: conversation.isGroup
)
Text(conversation.displayName(currentUserId: currentUserId))
.font(.headline)
if !conversation.isGroup && verificationStatus == "verified" {
Image(systemName: "checkmark.shield.fill")
.font(.caption)
.foregroundStyle(.green)
}
if isPartnerOnline {
Circle().fill(.green).frame(width: 8, height: 8)
}
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
if !conversation.isGroup {
Button(action: { showVerification = true }) {
Image(systemName: verificationStatus == "verified" ? "checkmark.shield.fill" : "shield")
.foregroundStyle(verificationStatus == "verified" ? .green : .secondary)
}
}
Button(action: { showPinnedMessages = true }) {
Image(systemName: "pin")
}
Button(action: { showSearch.toggle() }) {
Image(systemName: "magnifyingglass")
}
if conversation.isGroup {
Button(action: { showGroupInfo = true }) {
Image(systemName: "info.circle")
}
} else {
Button(action: { showDMInfo = true }) {
Image(systemName: "info.circle")
}
}
if !conversation.isGroup || conversation.createdBy == currentUserId {
Button(action: { showDeleteConfirm = true }) {
Image(systemName: "trash").foregroundStyle(.red)
}
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
import SwiftUI
struct ForwardPickerView: View {
let message: Message
let appState: AppState
@Environment(\.dismiss) private var dismiss
@State private var conversations: [Conversation] = []
@State private var isLoading = true
@State private var isSending = false
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading conversations...")
} else if conversations.isEmpty {
Text("No conversations available")
.foregroundStyle(.secondary)
} else {
List(conversations) { conv in
Button {
forwardTo(conv)
} label: {
HStack {
CircularAvatarView(
name: conv.displayName(currentUserId: appState.currentUser?.id ?? ""),
size: 36,
isGroup: conv.isGroup
)
Text(conv.displayName(currentUserId: appState.currentUser?.id ?? ""))
Spacer()
}
}
.disabled(isSending)
}
}
}
.navigationTitle("Forward to...")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.task {
conversations = await appState.chatClient.listConversations()
isLoading = false
}
}
private func forwardTo(_ conv: Conversation) {
isSending = true
Task {
let forwardPayload: [String: Any] = [
"forwarded_from": [
"sender": message.senderUsername,
"conversation_id": message.conversationId,
"message_id": message.id,
] as [String: Any]
]
let (success, _, _) = await appState.chatClient.sendMessage(
convId: conv.id,
text: message.text ?? "",
members: conv.members,
extraPayload: forwardPayload
)
await MainActor.run {
isSending = false
if success {
dismiss()
}
}
}
}
}

View File

@@ -0,0 +1,113 @@
import SwiftUI
import UIKit
import Photos
struct ImageViewerView: View {
let imageData: Data
@State private var scale: CGFloat = 1.0
@State private var saved = false
@State private var saveError: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
GeometryReader { geo in
if let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.gesture(
MagnifyGesture()
.onChanged { value in
scale = value.magnification
}
.onEnded { _ in
withAnimation {
scale = max(1.0, min(scale, 5.0))
}
}
)
.onTapGesture(count: 2) {
withAnimation {
scale = scale > 1 ? 1 : 2
}
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
.overlay(alignment: .bottom) {
if let error = saveError {
Text(error)
.font(.caption)
.foregroundStyle(.white)
.padding(8)
.background(Capsule().fill(.red.opacity(0.8)))
.padding(.bottom, 40)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.foregroundStyle(.white)
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
// Share
if let uiImage = UIImage(data: imageData) {
ShareLink(item: Image(uiImage: uiImage), preview: SharePreview("Image", image: Image(uiImage: uiImage))) {
Image(systemName: "square.and.arrow.up")
.foregroundStyle(.white)
}
}
// Save to Photos
Button {
saveToPhotos()
} label: {
Image(systemName: saved ? "checkmark.circle.fill" : "arrow.down.to.line")
.foregroundStyle(saved ? .green : .white)
}
}
}
}
.toolbarBackground(.hidden, for: .navigationBar)
.background(.black)
}
}
private func saveToPhotos() {
guard let uiImage = UIImage(data: imageData) else {
withAnimation { saveError = "Invalid image data" }
return
}
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
DispatchQueue.main.async {
switch status {
case .authorized, .limited:
PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: uiImage)
} completionHandler: { success, error in
DispatchQueue.main.async {
if success {
withAnimation { saved = true; saveError = nil }
} else {
withAnimation { saveError = error?.localizedDescription ?? "Save failed" }
}
}
}
case .denied, .restricted:
withAnimation { saveError = "Photo library access denied. Check Settings." }
default:
withAnimation { saveError = "Photo library access required" }
}
}
}
}
}

View File

@@ -0,0 +1,558 @@
import SwiftUI
import UIKit
struct MessageBubbleView: View {
let message: Message
let isMine: Bool
var isGroup: Bool = false
var isHighlighted: Bool = false
var isCurrentSearchResult: Bool = false
var chatClient: ChatClient?
var currentUserId: String = ""
var onReply: (() -> Void)?
var onReact: ((String) -> Void)?
var onForward: (() -> Void)?
var onPin: ((Bool) -> Void)?
var onDelete: (() -> Void)?
@State private var fullImageData: Data?
@State private var showFullImage = false
@State private var isLoadingImage = false
@State private var isLoadingFile = false
@State private var downloadedFileURL: URL?
@State private var showShareSheet = false
@State private var imageError: String?
var body: some View {
HStack {
if isMine { Spacer(minLength: 60) }
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
if !isMine && isGroup {
Text(message.senderUsername)
.font(.caption.bold())
.foregroundStyle(.secondary)
}
if message.isDeleted {
Text("Message deleted")
.font(.body.italic())
.foregroundStyle(.secondary)
.padding(12)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 16))
} else {
// Forwarded header
if let fwd = message.forwardedFrom {
HStack(spacing: 4) {
Rectangle().fill(.cyan).frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text("Forwarded from").font(.caption2).foregroundStyle(.secondary)
Text(fwd.sender).font(.caption.bold()).foregroundStyle(.cyan)
}
}
.padding(.horizontal, 8).padding(.top, 4)
}
// Reply reference
if message.replyTo != nil {
HStack(spacing: 4) {
Rectangle()
.fill(.blue.opacity(0.5))
.frame(width: 2)
Text("Reply to message")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
// Image thumbnail
if let imageInfo = message.image {
imageView(imageInfo: imageInfo)
}
// File card
if let file = message.file {
VStack(alignment: .leading, spacing: 4) {
HStack {
if isLoadingFile {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: fileIcon(for: file.filename))
}
Text(file.filename)
.lineLimit(1)
}
.font(.subheadline)
Text(formatFileSize(file.size))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(12)
.background(Color(.systemGray5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.onTapGesture {
downloadAndShareFile(file: file)
}
}
// Text content with link detection
if let text = message.text, !text.isEmpty {
LinkText(text: text, isMine: isMine)
.padding(12)
.background(
isMine ? Color.blue : Color(.systemGray5)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
// Timestamp + checkmarks + reactions all on one line
HStack(spacing: 4) {
if message.pinnedAt != nil {
Image(systemName: "pin.fill").font(.caption2).foregroundStyle(.orange)
}
Text(formatTime(message.createdAt)).font(.caption2).foregroundStyle(.secondary)
if isMine {
deliveryIndicator
}
if !message.reactions.isEmpty {
inlineReactionBadges
}
}
.frame(maxWidth: .infinity, alignment: isMine ? .trailing : .leading)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(isCurrentSearchResult ? Color.orange.opacity(0.3) :
isHighlighted ? Color.yellow.opacity(0.2) : Color.clear)
)
.contextMenu {
if !message.isDeleted {
Button(action: { onReply?() }) {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
Menu {
ForEach(ReactionEmoji.allowed, id: \.self) { key in
Button("\(ReactionEmoji.display[key] ?? "") \(key)") { onReact?(key) }
}
} label: {
Label("React", systemImage: "face.smiling")
}
Button(action: {
UIPasteboard.general.string = message.text ?? ""
// Auto-clear clipboard after 30 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
if UIPasteboard.general.string == message.text {
UIPasteboard.general.string = ""
}
}
}) {
Label("Copy", systemImage: "doc.on.doc")
}
Button(action: { onForward?() }) {
Label("Forward", systemImage: "arrowshape.turn.up.right")
}
Button(action: { onPin?(message.pinnedAt == nil) }) {
Label(message.pinnedAt == nil ? "Pin" : "Unpin",
systemImage: message.pinnedAt == nil ? "pin" : "pin.slash")
}
if isMine {
Button(role: .destructive, action: { onDelete?() }) {
Label("Delete", systemImage: "trash")
}
}
}
}
if !isMine { Spacer(minLength: 60) }
}
.sheet(isPresented: $showFullImage) {
if let data = fullImageData {
ImageViewerView(imageData: data)
}
}
.sheet(isPresented: $showShareSheet, onDismiss: {
// Clean up decrypted temp file after sharing
if let fileURL = downloadedFileURL {
try? FileManager.default.removeItem(at: fileURL)
downloadedFileURL = nil
}
}) {
if let fileURL = downloadedFileURL {
ActivityViewController(activityItems: [fileURL])
}
}
}
// MARK: - Reaction Badges (inline used in timestamp row)
private var inlineReactionBadges: some View {
let grouped = Dictionary(grouping: message.reactions, by: \.reaction)
return HStack(spacing: 2) {
ForEach(grouped.sorted(by: { $0.key < $1.key }), id: \.key) { reaction, users in
Button {
onReact?(reaction)
} label: {
Text(ReactionEmoji.display[reaction] ?? reaction)
.font(.caption)
}
.buttonStyle(.plain)
}
}
}
// MARK: - Delivery Indicator (checkmarks)
@ViewBuilder
private var deliveryIndicator: some View {
let isRead = message.readBy.contains(where: { $0 != "__delivered__" && $0 != currentUserId })
let isDelivered = message.readBy.contains("__delivered__")
if isRead {
// Read: 2 green checkmarks
HStack(spacing: -4) {
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
}
.foregroundStyle(.green)
} else if isDelivered {
// Delivered: 2 gray checkmarks
HStack(spacing: -4) {
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
}
.foregroundStyle(.secondary)
} else {
// Sent: 1 gray checkmark
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
.foregroundStyle(.secondary)
}
}
// MARK: - Image View
@ViewBuilder
private func imageView(imageInfo: ImageInfo) -> some View {
VStack(spacing: 4) {
if let thumbB64 = imageInfo.thumbnail,
let thumbData = Data(base64Encoded: thumbB64),
let uiImage = UIImage(data: thumbData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 220, maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay {
if isLoadingImage {
RoundedRectangle(cornerRadius: 12)
.fill(.black.opacity(0.4))
ProgressView()
.tint(.white)
}
}
.onTapGesture {
downloadAndShowFullImage(imageInfo: imageInfo)
}
} else {
// No thumbnail available show placeholder
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray5))
.frame(width: 160, height: 120)
.overlay {
if isLoadingImage {
ProgressView()
} else {
VStack(spacing: 6) {
Image(systemName: "photo")
.font(.title2)
Text(imageInfo.filename)
.font(.caption)
.lineLimit(1)
}
.foregroundStyle(.secondary)
}
}
.onTapGesture {
downloadAndShowFullImage(imageInfo: imageInfo)
}
}
if let error = imageError {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.onTapGesture {
imageError = nil
downloadAndShowFullImage(imageInfo: imageInfo)
}
}
}
}
private func downloadAndShowFullImage(imageInfo: ImageInfo) {
guard !isLoadingImage, let client = chatClient else { return }
// If already downloaded, show immediately
if fullImageData != nil {
showFullImage = true
return
}
imageError = nil
isLoadingImage = true
Task {
guard let aesKey = try? ProtocolHandler.decodeBinary(imageInfo.aesKey),
let iv = try? ProtocolHandler.decodeBinary(imageInfo.iv) else {
await MainActor.run {
isLoadingImage = false
imageError = "Failed to decode image keys"
}
return
}
let data = await client.downloadFile(fileId: imageInfo.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId)
await MainActor.run {
isLoadingImage = false
if let data = data {
fullImageData = data
showFullImage = true
} else {
imageError = "Download failed, tap to retry"
}
}
}
}
// MARK: - File Download
private func downloadAndShareFile(file: FileInfo) {
guard !isLoadingFile, let client = chatClient else { return }
// If already downloaded, show share sheet immediately
if downloadedFileURL != nil {
showShareSheet = true
return
}
isLoadingFile = true
Task {
guard let aesKey = try? ProtocolHandler.decodeBinary(file.aesKey),
let iv = try? ProtocolHandler.decodeBinary(file.iv) else {
await MainActor.run { isLoadingFile = false }
return
}
let data = await client.downloadFile(fileId: file.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId)
await MainActor.run {
isLoadingFile = false
if let data = data {
// Save to temp with file protection, clean up on dismiss
let tempDir = FileManager.default.temporaryDirectory
let fileURL = tempDir.appendingPathComponent(file.filename)
try? data.write(to: fileURL, options: .completeFileProtection)
downloadedFileURL = fileURL
showShareSheet = true
}
}
}
}
private func fileIcon(for filename: String) -> String {
let ext = (filename as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "doc.richtext"
case "doc", "docx": return "doc.text"
case "xls", "xlsx": return "tablecells"
case "ppt", "pptx": return "rectangle.on.rectangle"
case "zip", "rar", "7z": return "doc.zipper"
case "mp3", "wav", "m4a": return "music.note"
case "mp4", "mov", "avi": return "film"
case "txt": return "doc.plaintext"
default: return "paperclip"
}
}
// MARK: - Helpers
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else {
formatter.dateFormat = "MMM d, HH:mm"
}
return formatter.string(from: date)
}
private func formatFileSize(_ bytes: Int) -> String {
if bytes < 1024 { return "\(bytes) B" }
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
}
}
// MARK: - Link Text
struct LinkText: View {
let text: String
let isMine: Bool
var body: some View {
Text(buildAttributedString())
.environment(\.openURL, OpenURLAction { url in
UIApplication.shared.open(url)
return .handled
})
}
private func buildAttributedString() -> AttributedString {
var result = AttributedString()
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
}
let nsRange = NSRange(text.startIndex..., in: text)
let matches = detector.matches(in: text, range: nsRange)
if matches.isEmpty {
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
}
var lastEnd = text.startIndex
for match in matches {
guard let matchRange = Range(match.range, in: text),
let url = match.url else { continue }
// Plain text before link (with mention highlighting)
if lastEnd < matchRange.lowerBound {
appendPlainWithMentions(text[lastEnd..<matchRange.lowerBound], to: &result)
}
// Link
let isSecure = url.scheme?.lowercased() == "https"
var link = AttributedString(text[matchRange])
link.link = url
link.underlineStyle = .single
if isMine {
link.foregroundColor = isSecure ? .cyan : .red
} else {
link.foregroundColor = isSecure ? .blue : .red
}
result.append(link)
lastEnd = matchRange.upperBound
}
// Remaining text
if lastEnd < text.endIndex {
appendPlainWithMentions(text[lastEnd..<text.endIndex], to: &result)
}
return result
}
private static let mentionRegex = try! NSRegularExpression(pattern: "@(\\w+)", options: [])
@discardableResult
private func appendPlainWithMentions(_ substring: Substring, to result: inout AttributedString) -> AttributedString {
let str = String(substring)
let nsRange = NSRange(str.startIndex..., in: str)
let matches = Self.mentionRegex.matches(in: str, range: nsRange)
if matches.isEmpty {
var plain = AttributedString(str)
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
return result
}
let mentionColor = Color(red: 0.537, green: 0.706, blue: 0.980)
var lastEnd = str.startIndex
for match in matches {
guard let matchRange = Range(match.range, in: str) else { continue }
if lastEnd < matchRange.lowerBound {
var plain = AttributedString(str[lastEnd..<matchRange.lowerBound])
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
}
var mention = AttributedString(str[matchRange])
mention.foregroundColor = mentionColor
mention.font = .body.bold()
result.append(mention)
lastEnd = matchRange.upperBound
}
if lastEnd < str.endIndex {
var plain = AttributedString(str[lastEnd..<str.endIndex])
plain.foregroundColor = isMine ? .white : .primary
result.append(plain)
}
return result
}
}
// MARK: - Flow Layout
struct FlowLayout: Layout {
var spacing: CGFloat = 4
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
return CGSize(width: maxWidth, height: y + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX && x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
// MARK: - Share Sheet
struct ActivityViewController: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@@ -0,0 +1,215 @@
import SwiftUI
import PhotosUI
import UniformTypeIdentifiers
import UIKit
struct MessageInputView: View {
@Binding var text: String
let isSending: Bool
let onSend: () -> Void
var onImageSelected: ((Data) -> Void)?
var onFileSelected: ((Data, String, String) -> Void)? // data, filename, mimeType
var members: [ConversationMember] = []
@State private var isProcessing = false
@State private var showFilePicker = false
@State private var showPhotoPicker = false
@State private var showMentionPopup = false
@State private var mentionCandidates: [ConversationMember] = []
var body: some View {
VStack(spacing: 0) {
// Mention autocomplete popup
if showMentionPopup && !mentionCandidates.isEmpty {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ForEach(mentionCandidates) { member in
Button {
completeMention(member: member)
} label: {
Text("@\(member.username)")
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
Divider()
}
}
}
.frame(maxHeight: 150)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal)
}
HStack(spacing: 8) {
// Attach button
Menu {
Button {
showPhotoPicker = true
} label: {
Label("Photo", systemImage: "photo")
}
Button {
showFilePicker = true
} label: {
Label("File", systemImage: "doc")
}
} label: {
if isProcessing {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
}
.disabled(isProcessing || isSending)
// Text field
TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.onSubmit {
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
onSend()
}
}
// Send button
Button(action: onSend) {
if isSending {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(.blue)
}
}
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.sheet(isPresented: $showPhotoPicker) {
ImagePickerView { data in
isProcessing = true
onImageSelected?(data)
isProcessing = false
}
}
.sheet(isPresented: $showFilePicker) {
DocumentPickerView { url in
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
guard let data = try? Data(contentsOf: url) else { return }
let filename = url.lastPathComponent
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
onFileSelected?(data, filename, mimeType)
}
}
.onChange(of: text) {
updateMentionCandidates()
}
} // end VStack
}
private func updateMentionCandidates() {
// Look for @prefix at end of text
guard let atRange = text.range(of: "@\\w*$", options: .regularExpression) else {
showMentionPopup = false
mentionCandidates = []
return
}
let prefix = String(text[atRange]).dropFirst().lowercased() // remove @
mentionCandidates = members.filter { member in
prefix.isEmpty || member.username.lowercased().hasPrefix(prefix)
}
showMentionPopup = !mentionCandidates.isEmpty
}
private func completeMention(member: ConversationMember) {
if let atRange = text.range(of: "@\\w*$", options: .regularExpression) {
text.replaceSubrange(atRange, with: "@\(member.username) ")
}
showMentionPopup = false
mentionCandidates = []
}
}
// MARK: - Image Picker (UIKit PHPicker wrapper)
struct ImagePickerView: UIViewControllerRepresentable {
let onImagePicked: (Data) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onImagePicked: onImagePicked)
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onImagePicked: (Data) -> Void
init(onImagePicked: @escaping (Data) -> Void) {
self.onImagePicked = onImagePicked
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
guard let uiImage = image as? UIImage,
let data = uiImage.jpegData(compressionQuality: 0.9) else { return }
DispatchQueue.main.async {
self?.onImagePicked(data)
}
}
}
}
}
// MARK: - Document Picker (UIKit wrapper)
struct DocumentPickerView: UIViewControllerRepresentable {
let onPick: (URL) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onPick: onPick)
}
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item])
picker.delegate = context.coordinator
picker.allowsMultipleSelection = false
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
class Coordinator: NSObject, UIDocumentPickerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) {
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}
}

View File

@@ -0,0 +1,62 @@
import SwiftUI
struct PinnedMessagesView: View {
let messages: [Message]
var onScrollTo: ((String) -> Void)?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Group {
if messages.isEmpty {
Text("No pinned messages")
.foregroundStyle(.secondary)
} else {
List(messages) { message in
Button {
dismiss()
onScrollTo?(message.id)
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "pin.fill")
.font(.caption)
.foregroundStyle(.orange)
Text(message.senderUsername)
.font(.caption.bold())
Spacer()
Text(formatTime(message.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(message.text ?? "")
.font(.body)
.lineLimit(3)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
}
.navigationTitle("Pinned Messages")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
private func formatTime(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else {
formatter.dateFormat = "MMM d, HH:mm"
}
return formatter.string(from: date)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct SearchOverlayView: View {
@Binding var query: String
let matchCount: Int
let currentIndex: Int
let onSearch: (String) -> Void
let onNext: () -> Void
let onPrev: () -> Void
let onClose: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search messages", text: $query)
.textFieldStyle(.roundedBorder)
.onChange(of: query) { _, newValue in
onSearch(newValue)
}
if matchCount > 0 {
Text("\(currentIndex + 1)/\(matchCount)")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize()
Button(action: onPrev) {
Image(systemName: "chevron.up")
}
Button(action: onNext) {
Image(systemName: "chevron.down")
}
}
Button(action: onClose) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
struct CircularAvatarView: View {
let name: String
var imageData: Data?
var size: CGFloat = 32
var isGroup: Bool = false
var body: some View {
if let imageData = imageData, let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(Circle())
} else {
// Default: colored circle with initial letter
ZStack {
Circle()
.fill(avatarColor)
.frame(width: size, height: size)
Text(initial)
.font(.system(size: size * 0.4, weight: .semibold))
.foregroundStyle(.white)
}
}
}
private var initial: String {
String(name.prefix(1)).uppercased()
}
/// Deterministic color from name hash (matching Python gui_client behavior)
private var avatarColor: Color {
let colors: [Color] = [
.red, .orange, .yellow, .green, .mint,
.teal, .cyan, .blue, .indigo, .purple, .pink
]
var hash = 0
for char in name.unicodeScalars {
hash = hash &* 31 &+ Int(char.value)
}
return colors[abs(hash) % colors.count]
}
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
struct ConnectionIndicator: View {
let status: ConnectionStatus
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
if status != .connected {
Text(statusText)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private var statusColor: Color {
switch status {
case .connected: return .green
case .connecting, .reconnecting: return .orange
case .disconnected: return .red
}
}
private var statusText: String {
switch status {
case .connected: return ""
case .connecting: return "Connecting..."
case .reconnecting: return "Reconnecting..."
case .disconnected: return "Disconnected"
}
}
}

View File

@@ -0,0 +1,15 @@
import SwiftUI
struct OnlineDotOverlay: View {
var size: CGFloat = 12
var body: some View {
Circle()
.fill(.green)
.frame(width: size, height: size)
.overlay(
Circle()
.stroke(.white, lineWidth: 2)
)
}
}

View File

@@ -0,0 +1,101 @@
import SwiftUI
struct ConversationListView: View {
var appState: AppState
@Bindable var viewModel: ConversationListVM
@State private var showNewConversation = false
@State private var showProfile = false
@State private var selectedConversation: Conversation?
var body: some View {
NavigationStack {
List {
// Invitations section
if !viewModel.invitations.isEmpty {
Section {
ForEach(viewModel.invitations) { invitation in
InvitationBanner(
invitation: invitation,
onAccept: {
Task {
let (success, _) = await appState.chatClient.acceptInvitation(convId: invitation.conversationId)
if success {
await viewModel.refresh(chatClient: appState.chatClient)
}
}
},
onDecline: {
Task {
_ = await appState.chatClient.declineInvitation(convId: invitation.conversationId)
await viewModel.refresh(chatClient: appState.chatClient)
}
}
)
}
} header: {
Text("Invitations")
}
}
// Conversations section
Section {
ForEach(viewModel.conversations) { conversation in
NavigationLink(value: conversation) {
ConversationRowView(
conversation: conversation,
currentUserId: appState.currentUser?.id ?? "",
isOnline: conversation.dmPartnerId(currentUserId: appState.currentUser?.id ?? "")
.map { viewModel.onlineUsers.contains($0) } ?? false,
unreadCount: viewModel.unreadCounts[conversation.id] ?? 0,
avatarData: viewModel.avatarCache[conversation.id]
)
}
.contextMenu {
Button(conversation.isFavorite ? "Remove from Favorites" : "Add to Favorites") {
viewModel.toggleFavorite(convId: conversation.id, email: appState.email)
}
}
}
}
}
.navigationTitle("Chats")
.navigationDestination(for: Conversation.self) { conversation in
ChatView(
conversation: conversation,
appState: appState,
conversationListVM: viewModel
)
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
ConnectionIndicator(status: appState.connectionStatus)
}
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button(action: { showProfile = true }) {
Image(systemName: "person.circle")
}
Button(action: { showNewConversation = true }) {
Image(systemName: "square.and.pencil")
}
}
}
}
.refreshable {
await viewModel.refresh(chatClient: appState.chatClient)
}
.sheet(isPresented: $showNewConversation) {
NewConversationSheet(appState: appState) { convId in
showNewConversation = false
await viewModel.refresh(chatClient: appState.chatClient)
}
}
.sheet(isPresented: $showProfile) {
ProfileView(appState: appState, isOwnProfile: true)
}
.task {
await viewModel.load(chatClient: appState.chatClient, email: appState.email)
}
}
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
struct ConversationRowView: View {
let conversation: Conversation
let currentUserId: String
let isOnline: Bool
let unreadCount: Int
var avatarData: Data?
var body: some View {
HStack(spacing: 12) {
// Avatar
ZStack(alignment: .bottomTrailing) {
CircularAvatarView(
name: conversation.displayName(currentUserId: currentUserId),
imageData: avatarData,
size: 44,
isGroup: conversation.isGroup
)
if isOnline && !conversation.isGroup {
OnlineDotOverlay(size: 12)
}
}
VStack(alignment: .leading, spacing: 2) {
HStack {
if conversation.isFavorite {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
Text(conversation.displayName(currentUserId: currentUserId))
.font(unreadCount > 0 ? .body.bold() : .body)
.lineLimit(1)
}
if conversation.isGroup {
Text("\(conversation.members.count) members")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if unreadCount > 0 {
Text("\(unreadCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.blue)
.clipShape(Capsule())
}
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
struct NewConversationSheet: View {
var appState: AppState
var onCreated: (String) async -> Void
@State private var email = ""
@State private var groupName = ""
@State private var isGroup = false
@State private var memberEmails: [String] = [""]
@State private var isLoading = false
@State private var errorMessage: String?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section {
Toggle("Create Group", isOn: $isGroup)
if isGroup {
TextField("Group Name", text: $groupName)
}
}
Section(isGroup ? "Members" : "Recipient") {
if isGroup {
ForEach(memberEmails.indices, id: \.self) { index in
TextField("Email", text: $memberEmails[index])
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
Button("Add Member") {
memberEmails.append("")
}
} else {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
}
if let error = errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
}
.navigationTitle("New Conversation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
Task { await create() }
}
.disabled(isLoading)
}
}
}
}
private func create() async {
isLoading = true
errorMessage = nil
let emails: [String]
if isGroup {
emails = memberEmails.map { $0.trimmed }.filter { !$0.isEmpty }
guard !emails.isEmpty else {
errorMessage = "Add at least one member"
isLoading = false
return
}
} else {
guard !email.trimmed.isEmpty else {
errorMessage = "Enter an email address"
isLoading = false
return
}
emails = [email.trimmed]
}
let name = isGroup && !groupName.trimmed.isEmpty ? groupName.trimmed : nil
let (convId, message) = await appState.chatClient.createConversation(emails: emails, name: name)
isLoading = false
if let convId = convId {
await onCreated(convId)
} else {
errorMessage = message
}
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Group creation is handled within NewConversationSheet via the isGroup toggle.
// This file exists for potential future separation.

View File

@@ -0,0 +1,301 @@
import SwiftUI
import PhotosUI
struct GroupInfoView: View {
@Binding var conversation: Conversation
var appState: AppState
var conversationListVM: ConversationListVM?
@State private var showRenameSheet = false
@State private var showLeaveConfirm = false
@State private var showAddMember = false
@State private var showRemoveConfirm = false
@State private var showAvatarPicker = false
@State private var newName = ""
@State private var addMemberEmail = ""
@State private var memberToRemove: ConversationMember?
@State private var errorMessage: String?
@State private var showError = false
@State private var isUploadingAvatar = false
@State private var groupAvatarData: Data?
@Environment(\.dismiss) private var dismiss
private var isCreator: Bool {
conversation.createdBy == appState.currentUser?.id
}
private func refreshConversation() async {
let convs = await appState.chatClient.listConversations()
if let updated = convs.first(where: { $0.id == conversation.id }) {
conversation = updated
}
await conversationListVM?.forceRefresh(chatClient: appState.chatClient)
}
var body: some View {
NavigationStack {
List {
// Avatar section
Section {
HStack {
Spacer()
VStack(spacing: 8) {
CircularAvatarView(
name: conversation.name ?? "Group",
imageData: groupAvatarData ?? conversationListVM?.avatarCache[conversation.id],
size: 64,
isGroup: true
)
Text(conversation.name ?? "Group")
.font(.title2.bold())
Text("\(conversation.members.count) members")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.listRowBackground(Color.clear)
}
// Actions
if isCreator {
Section {
Button("Add Member") {
addMemberEmail = ""
showAddMember = true
}
Button("Rename Group") {
newName = conversation.name ?? ""
showRenameSheet = true
}
Button {
showAvatarPicker = true
} label: {
HStack {
Text("Change Avatar")
if isUploadingAvatar {
Spacer()
ProgressView()
.scaleEffect(0.8)
}
}
}
.disabled(isUploadingAvatar)
}
}
// Members
Section("Members") {
ForEach(conversation.members) { member in
HStack {
CircularAvatarView(name: member.username, size: 32, isGroup: false)
VStack(alignment: .leading) {
Text(member.username)
.font(.body)
Text(member.email)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if member.userId == conversation.createdBy {
Text("Admin")
.font(.caption)
.foregroundStyle(.blue)
}
}
.contextMenu {
if isCreator && member.userId != appState.currentUser?.id {
Button("Remove from Group", role: .destructive) {
memberToRemove = member
showRemoveConfirm = true
}
}
}
}
}
// Leave / Delete
Section {
Button("Leave Group", role: .destructive) {
showLeaveConfirm = true
}
if isCreator {
Button("Delete Group", role: .destructive) {
Task {
_ = await appState.chatClient.deleteConversation(convId: conversation.id)
dismiss()
}
}
}
}
}
.navigationTitle("Group Info")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.alert("Leave Group?", isPresented: $showLeaveConfirm) {
Button("Cancel", role: .cancel) {}
Button("Leave", role: .destructive) {
Task {
_ = await appState.chatClient.leaveGroup(convId: conversation.id)
dismiss()
}
}
}
.alert("Remove Member?", isPresented: $showRemoveConfirm) {
Button("Cancel", role: .cancel) {}
Button("Remove", role: .destructive) {
if let member = memberToRemove {
Task {
let (success, msg) = await appState.chatClient.removeMember(
convId: conversation.id, userId: member.userId
)
if success {
await refreshConversation()
} else {
errorMessage = msg
showError = true
}
}
}
}
} message: {
if let member = memberToRemove {
Text("Remove \(member.username) from the group?")
}
}
.alert("Add Member", isPresented: $showAddMember) {
TextField("Email", text: $addMemberEmail)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
Button("Cancel", role: .cancel) {}
Button("Add") {
let email = addMemberEmail.trimmingCharacters(in: .whitespacesAndNewlines)
guard !email.isEmpty else { return }
Task {
let (success, msg) = await appState.chatClient.addMember(
convId: conversation.id, email: email
)
if success {
await refreshConversation()
} else {
errorMessage = msg
showError = true
}
}
}
}
.alert("Rename Group", isPresented: $showRenameSheet) {
TextField("Group Name", text: $newName)
Button("Cancel", role: .cancel) {}
Button("Rename") {
let trimmedName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return }
// Optimistic update - immediately reflect in UI
conversation.name = trimmedName
Task {
let (success, _) = await appState.chatClient.renameConversation(convId: conversation.id, name: trimmedName)
if success {
await refreshConversation()
} else {
// Revert on failure
await refreshConversation()
}
}
}
}
.alert("Error", isPresented: $showError) {
Button("OK") {}
} message: {
Text(errorMessage ?? "")
}
.sheet(isPresented: $showAvatarPicker) {
AvatarPickerView { imageData in
isUploadingAvatar = true
Task {
let success = await appState.chatClient.updateGroupAvatar(
convId: conversation.id, imageData: imageData
)
isUploadingAvatar = false
if success {
// Update local avatar cache (memory + disk)
groupAvatarData = imageData
conversationListVM?.updateAvatar(convId: conversation.id, data: imageData)
await refreshConversation()
} else {
errorMessage = "Failed to update avatar"
showError = true
}
}
}
}
.task {
// Load current group avatar
if groupAvatarData == nil, let cached = conversationListVM?.avatarCache[conversation.id] {
groupAvatarData = cached
} else if groupAvatarData == nil {
groupAvatarData = await appState.chatClient.getGroupAvatar(convId: conversation.id)
}
await refreshConversation()
}
}
}
}
// MARK: - Avatar Picker (PHPicker wrapper for avatar selection)
private struct AvatarPickerView: UIViewControllerRepresentable {
let onImagePicked: (Data) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onImagePicked: onImagePicked)
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onImagePicked: (Data) -> Void
init(onImagePicked: @escaping (Data) -> Void) {
self.onImagePicked = onImagePicked
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) else {
picker.dismiss(animated: true)
return
}
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
guard let uiImage = image as? UIImage,
let data = uiImage.jpegData(compressionQuality: 0.8) else {
DispatchQueue.main.async { picker.dismiss(animated: true) }
return
}
DispatchQueue.main.async {
self?.onImagePicked(data)
picker.dismiss(animated: true)
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
struct InvitationBanner: View {
let invitation: Invitation
let onAccept: () -> Void
let onDecline: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "envelope.badge")
.foregroundStyle(.orange)
VStack(alignment: .leading) {
Text(invitation.conversationName)
.font(.body.bold())
Text("Invited by \(invitation.invitedByUsername)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
HStack(spacing: 12) {
Button("Accept") {
onAccept()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Button("Decline") {
onDecline()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,4 @@
import SwiftUI
// Profile editing is handled within ProfileView when isOwnProfile = true.
// This file exists for potential future separation.

View File

@@ -0,0 +1,277 @@
import SwiftUI
import PhotosUI
import UIKit
struct ProfileView: View {
var appState: AppState
var isOwnProfile: Bool
var userId: String?
@State private var viewModel = ProfileViewModel()
@State private var showLogoutConfirm = false
@State private var showAvatarPicker = false
@State private var showAuthorizeDevice = false
@State private var showRotateKeys = false
@State private var rotatePassword = ""
@State private var isRotating = false
@State private var rotateMessage: String?
@State private var rotateIsError = false
@State private var showChangeUsername = false
@State private var newUsername = ""
@State private var showChangePassword = false
@State private var oldPassword = ""
@State private var newPassword = ""
@State private var confirmNewPassword = ""
@State private var showVerification = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
// Avatar
Section {
HStack {
Spacer()
VStack(spacing: 8) {
if let avatarData = viewModel.avatarData,
let uiImage = UIImage(data: avatarData) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(Circle())
} else {
CircularAvatarView(
name: viewModel.profile?.username ?? "?",
size: 80,
isGroup: false
)
}
if isOwnProfile {
Button("Change Photo") {
showAvatarPicker = true
}
.font(.caption)
}
}
Spacer()
}
.listRowBackground(Color.clear)
}
// Info
Section("Info") {
if let username = viewModel.profile?.username {
LabeledContent("Username", value: username)
}
if let email = viewModel.profile?.email {
LabeledContent("Email", value: email)
}
}
if isOwnProfile {
// Editable fields
Section("Contact") {
TextField("Phone", text: $viewModel.phone)
.keyboardType(.phonePad)
Toggle("Phone visible to contacts", isOn: $viewModel.phoneVisible)
TextField("Location", text: $viewModel.location)
Toggle("Location visible to contacts", isOn: $viewModel.locationVisible)
}
} else {
// Read-only view
if let phone = viewModel.profile?.phone, viewModel.profile?.phoneVisible == true {
Section("Contact") {
LabeledContent("Phone", value: phone)
}
}
if let location = viewModel.profile?.location, viewModel.profile?.locationVisible == true {
Section("Location") {
LabeledContent("Location", value: location)
}
}
}
if !isOwnProfile, let uid = userId {
Section("Security") {
NavigationLink {
SafetyNumberView(
peerUserId: uid,
peerUsername: viewModel.profile?.username ?? "User",
chatClient: appState.chatClient
)
} label: {
Label("Verify Identity", systemImage: "checkmark.shield")
}
}
}
if let error = viewModel.errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
if isOwnProfile {
Section("Account") {
Button {
newUsername = viewModel.profile?.username ?? ""
showChangeUsername = true
} label: {
Label("Change Username", systemImage: "person.text.rectangle")
}
Button {
showChangePassword = true
} label: {
Label("Change Password", systemImage: "key")
}
}
Section("Security") {
Button {
showAuthorizeDevice = true
} label: {
Label("Authorize New Device", systemImage: "iphone.badge.checkmark")
}
Button {
showRotateKeys = true
} label: {
Label("Rotate Keys", systemImage: "arrow.triangle.2.circlepath")
}
}
Section {
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
HStack {
Spacer()
Text("Logout")
Spacer()
}
}
}
}
}
.navigationTitle(isOwnProfile ? "My Profile" : "Profile")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if isOwnProfile {
Button("Save") {
Task {
let success = await viewModel.saveProfile(chatClient: appState.chatClient)
if success {
dismiss()
}
}
}
.disabled(viewModel.isSaving)
}
}
ToolbarItem(placement: .topBarLeading) {
if !isOwnProfile {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
}
}
}
}
.alert("Logout", isPresented: $showLogoutConfirm) {
Button("Cancel", role: .cancel) {}
Button("Logout", role: .destructive) {
Task {
await appState.logout()
}
}
} message: {
Text("Are you sure you want to logout?")
}
.sheet(isPresented: $showAvatarPicker) {
ImagePickerView { data in
Task {
await viewModel.uploadAvatar(imageData: data, chatClient: appState.chatClient)
}
}
}
.sheet(isPresented: $showAuthorizeDevice) {
AuthorizeDeviceView(appState: appState)
}
.alert("Rotate Keys", isPresented: $showRotateKeys) {
SecureField("Password", text: $rotatePassword)
Button("Cancel", role: .cancel) { rotatePassword = "" }
Button("Rotate") {
Task {
isRotating = true
let (success, msg) = await appState.chatClient.rotateKeys(password: rotatePassword)
rotatePassword = ""
isRotating = false
rotateMessage = msg
rotateIsError = !success
}
}
} message: {
Text("Enter your password to generate new keys. All other devices will be disconnected.")
}
.alert(rotateIsError ? "Error" : "Success", isPresented: Binding(
get: { rotateMessage != nil },
set: { if !$0 { rotateMessage = nil } }
)) {
Button("OK") { rotateMessage = nil }
} message: {
Text(rotateMessage ?? "")
}
.alert("Change Username", isPresented: $showChangeUsername) {
TextField("New username", text: $newUsername)
Button("Cancel", role: .cancel) { newUsername = "" }
Button("Change") {
Task {
let success = await viewModel.changeUsername(newUsername: newUsername, chatClient: appState.chatClient)
if success {
await viewModel.loadProfile(chatClient: appState.chatClient)
}
newUsername = ""
}
}
} message: {
Text("Enter a new display name.")
}
.alert("Change Password", isPresented: $showChangePassword) {
SecureField("Current password", text: $oldPassword)
SecureField("New password", text: $newPassword)
SecureField("Confirm new password", text: $confirmNewPassword)
Button("Cancel", role: .cancel) {
oldPassword = ""
newPassword = ""
confirmNewPassword = ""
}
Button("Change") {
Task {
guard newPassword == confirmNewPassword else {
viewModel.errorMessage = "Passwords don't match"
return
}
_ = await viewModel.changePassword(
oldPassword: oldPassword, newPassword: newPassword,
chatClient: appState.chatClient
)
oldPassword = ""
newPassword = ""
confirmNewPassword = ""
}
}
} message: {
Text("Enter your current password and a new password.")
}
.task {
await viewModel.loadProfile(userId: userId, chatClient: appState.chatClient)
}
}
}
}

View File

@@ -0,0 +1,149 @@
import SwiftUI
import AVFoundation
struct QRCodeScannerView: View {
let onScan: (Data) -> Void
@Environment(\.dismiss) private var dismiss
@State private var cameraPermission: CameraPermission = .unknown
enum CameraPermission {
case unknown, granted, denied
}
var body: some View {
NavigationStack {
ZStack {
switch cameraPermission {
case .unknown:
ProgressView("Requesting camera access...")
case .denied:
VStack(spacing: 16) {
Image(systemName: "camera.fill")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Camera access is required to scan QR codes.")
.multilineTextAlignment(.center)
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
}
.padding()
case .granted:
ScannerRepresentable(onScan: { data in
onScan(data)
dismiss()
})
.ignoresSafeArea()
}
}
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
}
}
.task {
await checkCameraPermission()
}
}
private func checkCameraPermission() async {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
cameraPermission = .granted
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .video)
cameraPermission = granted ? .granted : .denied
default:
cameraPermission = .denied
}
}
}
// MARK: - Scanner UIKit wrapper
private struct ScannerRepresentable: UIViewControllerRepresentable {
let onScan: (Data) -> Void
func makeUIViewController(context: Context) -> ScannerViewController {
let vc = ScannerViewController()
vc.onScan = onScan
return vc
}
func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {}
}
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
var onScan: ((Data) -> Void)?
private var captureSession: AVCaptureSession?
private var previewLayer: AVCaptureVideoPreviewLayer?
private var hasScanned = false
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupCamera()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
captureSession?.stopRunning()
}
private func setupCamera() {
let session = AVCaptureSession()
captureSession = session
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
return
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.bounds
view.layer.addSublayer(layer)
previewLayer = layer
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard !hasScanned,
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
object.type == .qr else { return }
hasScanned = true
captureSession?.stopRunning()
// QR codes contain base64-encoded binary data (matching Python client)
if let stringValue = object.stringValue,
let data = Data(base64Encoded: stringValue) {
onScan?(data)
}
}
}

View File

@@ -0,0 +1,144 @@
import SwiftUI
import CoreImage.CIFilterBuiltins
struct SafetyNumberView: View {
let peerUserId: String
let peerUsername: String
var chatClient: ChatClient
@State private var vm = VerificationVM()
@State private var showQRScanner = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Verification status badge
VerificationStatusView(status: vm.verificationStatus)
.padding(.top)
// Safety number
if let safetyNumber = vm.safetyNumber {
VStack(spacing: 8) {
Text("Safety Number")
.font(.headline)
Text("If both you and \(peerUsername) see the same number, your communication is secure.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Text(safetyNumber)
.font(.system(.title2, design: .monospaced))
.padding()
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// QR Code
if let qrData = vm.qrCodeData {
VStack(spacing: 8) {
Text("Your QR Code")
.font(.headline)
if let qrImage = generateQRCode(from: qrData) {
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
.padding()
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// Fingerprints
VStack(spacing: 12) {
if let myFP = vm.myFingerprint {
VStack(spacing: 4) {
Text("Your Fingerprint")
.font(.subheadline.bold())
Text(myFP)
.font(.system(.caption, design: .monospaced))
}
}
if let peerFP = vm.peerFingerprint {
VStack(spacing: 4) {
Text("\(peerUsername)'s Fingerprint")
.font(.subheadline.bold())
Text(peerFP)
.font(.system(.caption, design: .monospaced))
}
}
}
// Actions
VStack(spacing: 12) {
if vm.verificationStatus != "verified" {
Button {
Task { await vm.verifyContact(peerUserId: peerUserId, chatClient: chatClient) }
} label: {
Label("Mark as Verified", systemImage: "checkmark.shield.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
showQRScanner = true
} label: {
Label("Scan QR Code", systemImage: "qrcode.viewfinder")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
} else {
Button(role: .destructive) {
Task { await vm.unverifyContact(peerUserId: peerUserId, chatClient: chatClient) }
} label: {
Label("Remove Verification", systemImage: "xmark.shield")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
.padding(.horizontal)
// Scan result
if let result = vm.scanResult {
Text(result)
.font(.callout)
.foregroundStyle(vm.scanSuccess == true ? .green : .red)
.padding()
}
}
.padding()
}
.navigationTitle("Verify \(peerUsername)")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showQRScanner) {
QRCodeScannerView { scannedData in
showQRScanner = false
Task { await vm.verifyQRCode(data: scannedData, chatClient: chatClient) }
}
}
.task {
await vm.loadVerification(peerUserId: peerUserId, chatClient: chatClient)
}
}
private func generateQRCode(from data: Data) -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
// Base64-encode binary data raw binary gets corrupted by QR readers (UTF-8 re-encoding)
let b64String = data.base64EncodedString()
filter.setValue(b64String.data(using: .ascii), forKey: "inputMessage")
filter.setValue("M", forKey: "inputCorrectionLevel")
guard let outputImage = filter.outputImage else { return nil }
let scale = 200.0 / outputImage.extent.width
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct VerificationStatusView: View {
let status: String // "verified", "trusted", "unverified"
var body: some View {
HStack(spacing: 6) {
Image(systemName: iconName)
.foregroundStyle(iconColor)
Text(displayText)
.font(.subheadline.bold())
.foregroundStyle(iconColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(iconColor.opacity(0.12))
.clipShape(Capsule())
}
private var iconName: String {
switch status {
case "verified": return "checkmark.shield.fill"
case "trusted": return "shield.fill"
default: return "shield.slash"
}
}
private var iconColor: Color {
switch status {
case "verified": return .green
case "trusted": return .blue
default: return .secondary
}
}
private var displayText: String {
switch status {
case "verified": return "Verified"
case "trusted": return "Trusted"
default: return "Unverified"
}
}
}

View File

@@ -0,0 +1,204 @@
# Security Review: Kecalek iOS v0.8.5
**Datum:** 14. března 2026
**Platforma:** iOS 26+ / Swift 6
**Architektura:** MVVM + Actor-based concurrency
**Rozsah:** 57 Swift souborů, ~11 500 řádků kódu
**Typ aplikace:** End-to-end encrypted messaging (Signal Protocol)
---
## 1. Shrnutí
Kecalek je kryptograficky vyspělá messaging aplikace postavená na Signal Protocol (Double Ratchet, X3DH, Sender Keys). Využívá výhradně nativní Apple frameworky bez externích závislostí. Celková bezpečnostní úroveň je vysoká — během review byly identifikovány převážně provozní nedostatky, nikoli fundamentální architekturální chyby.
---
## 2. Kryptografická architektura
### 2.1 Protokoly a algoritmy
| Komponenta | Implementace | Hodnocení |
|---|---|---|
| Key Exchange | X3DH (Extended Triple Diffie-Hellman) | Odpovídá Signal specifikaci |
| DM šifrování | Double Ratchet (AES-256-GCM) | Forward secrecy zajištěna |
| Skupinové šifrování | Sender Keys (AES-256-GCM) | Distribuce přes pairwise DM |
| Autentizace | RSA-4096 challenge-response (PSS-SHA256) | Silné |
| Úložiště klíčů | PBKDF2-HMAC-SHA256 (600 000 iterací) + AES-256-GCM | Odpovídající |
| Identity keys | Ed25519 (podepisování) + X25519 (key agreement) | Standard |
| KDF | HKDF-SHA256 | Standard |
| RNG | SecRandomCopyBytes | Kryptograficky bezpečný |
| Message padding | Bucket-based (64B64KB) | Skrývá délku zpráv |
### 2.2 Správa klíčů
- **Signed Pre-Key (SPK):** rotace každých 7 dní s grace periodem pro předchozí SPK
- **One-Time Pre-Keys (OPK):** batch 50 kusů, doplnění při prahu 20
- **Max skip:** 256 zpráv na chain (ochrana proti DoS přes vynucené přeskakování)
- **TOFU registry:** sledování identity klíčů kontaktů s možností manuální verifikace
- **Safety numbers:** 60místné číslo (SHA-512, 5 200 iterací) + QR kód verifikace
### 2.3 Pozitivní nálezy
- Žádné použití zastaralých algoritmů (MD5, SHA1, DES, RC4)
- Žádné vlastní kryptografické primitivy — vše přes CryptoKit a Security framework
- Správná implementace AAD (Associated Authenticated Data) v AES-GCM
- Snapshot/restore mechanismus pro atomické ratchet operace (M9 fix)
- Self-encryption pro multi-device synchronizaci vlastních zpráv
---
## 3. Nalezené zranitelnosti a nápravná opatření
### 3.1 KRITICKÉ — Opraveno
#### 3.1.1 Únik kryptografických klíčů přes debug výpisy
**Popis:** 160 `print()` volání v produkčním kódu vypisovalo citlivý kryptografický materiál do systémových logů — root keys, chain keys, message keys, identity keys, shared secrets, DH výstupy, nonce hodnoty.
**Riziko:** Na iOS jsou systémové logy čitelné přes USB (Console.app), diagnostické profily a potenciálně dalšími aplikacemi. Útočník s fyzickým přístupem k zařízení nebo se schopností číst logy mohl získat kompletní kryptografický stav relace.
**Dotčené soubory:**
- `Core/ChatClient.swift` — 118 výskytů (session data, OPK IDs, decryption debug)
- `Crypto/DoubleRatchet.swift` — 11 výskytů (root keys, DH public keys, message keys, nonce)
- `Crypto/X3DH.swift` — 9 výskytů (identity keys, ephemeral keys, DH outputs, shared secrets)
- `ViewModels/AuthViewModel.swift` — 7 výskytů
- `AppState.swift` — 7 výskytů
- `Core/MessageCache.swift` — 3 výskyty
- `ViewModels/ChatViewModel.swift` — 2 výskyty
- `ViewModels/ConversationListVM.swift` — 3 výskyty
**Náprava:** Všech 160 print statements zabaleno do `#if DEBUG` / `#endif` bloků. V release buildech nebude žádný kryptografický materiál logován.
#### 3.1.2 Insecure TLS — bypass ověření certifikátu a volitelné TLS
**Popis:** Parametr `tlsInsecure` umožňoval kompletní vypnutí TLS certificate verification. Navíc bylo TLS volitelné — uživatel mohl v UI vypnout šifrování transportní vrstvy přes toggle "Use TLS".
**Riziko:** MitM útok — útočník na síti mohl odposlouchávat a modifikovat veškerou komunikaci, včetně challenge-response autentizace a metadat.
**Dotčené soubory:**
- `Network/ConnectionManager.swift`
- `Core/ChatClient.swift`
- `ViewModels/AuthViewModel.swift`
- `Views/Auth/LoginView.swift`
- `Views/Auth/PairingView.swift`
- `Core/KeychainService.swift`
**Náprava:** TLS je nyní povinné bez výjimek. Parametry `useTLS` a `tlsInsecure` kompletně odstraněny z celého codebase. Toggle "Use TLS" odstraněn z UI. `ConnectionManager.connect()` vždy navazuje TLS spojení. Credentials v Keychainu již neukládají `useTLS` flag.
---
### 3.2 VYSOKÉ — Opraveno
#### 3.2.1 Heslo v paměti po úspěšném přihlášení
**Popis:** `AuthViewModel` uchovával heslo jako `String` property i po úspěšném loginu. Swift String je immutable a garbage collector jej může držet v paměti neomezeně dlouho.
**Riziko:** Memory dump útok — při fyzickém přístupu k zařízení nebo exploitu s přístupem do paměti procesu mohl útočník extrahovat heslo.
**Dotčený soubor:** `ViewModels/AuthViewModel.swift`
**Náprava:** Properties `password` a `confirmPassword` jsou vynulovány ihned po úspěšném přihlášení a uložení do Keychainu.
**Poznámka:** Swift `String` neumožňuje bezpečné přepisování paměti (na rozdíl od `UnsafeMutableBufferPointer`). Kompletní mitigace by vyžadovala vlastní typ pro citlivé řetězce. Aktuální řešení minimalizuje dobu expozice.
---
### 3.3 STŘEDNÍ — Opraveno
#### 3.3.1 Clipboard bez automatického vymazání
**Popis:** Funkce kopírování zprávy zapisovala text do systémového clipboardu (`UIPasteboard.general`) bez časového omezení.
**Riziko:** Jiné aplikace mohou číst obsah clipboardu (iOS 14+ zobrazuje notifikaci, ale nezabraňuje přístupu). Citlivý obsah zpráv mohl zůstat v clipboardu neomezeně.
**Dotčený soubor:** `Views/Chat/MessageBubbleView.swift`
**Náprava:** Přidán automatický clear clipboardu po 30 sekundách s kontrolou, že obsah nebyl mezitím uživatelem přepsán.
#### 3.3.2 Komentované vývojové IP adresy
**Popis:** `Constants.swift` obsahoval komentované dev server adresy (`192.168.88.65`, `85.71.71.188`), které odhalovaly interní síťovou infrastrukturu.
**Dotčený soubor:** `Utilities/Constants.swift`
**Náprava:** Komentované IP adresy odstraněny.
---
### 3.4 STŘEDNÍ — Neřešeno (doporučení)
#### 3.4.1 Chybějící certificate pinning
**Popis:** Aplikace se spoléhá výhradně na systémovou validaci TLS certifikátů. Neimplementuje certificate pinning ani SPKI pinning.
**Riziko:** Při kompromitaci certifikační autority, na enterprise-managed zařízeních s vlastním root CA, nebo při state-level útoku může útočník provést MitM. Dopady jsou omezené díky E2EE (obsah zpráv zůstává chráněn), ale metadata (kdo s kým komunikuje, timing) by byla vystavena.
**Doporučení:** Implementovat SPKI pinning pro produkční server `chat.ai-tech.news` pomocí Network.framework `sec_protocol_options_set_verify_block` s vlastní validací veřejného klíče serveru.
#### 3.4.2 Chybějící jailbreak detekce
**Popis:** Aplikace nedetekuje jailbreaknutá zařízení a nevaruje uživatele.
**Riziko:** Na jailbreaknutém zařízení jsou oslabeny iOS sandbox protekce — jiné aplikace mohou přistupovat k souborům aplikace, Keychain items mohou být extrahovány, a iOS file protection je částečně neúčinná.
**Doporučení:** Implementovat detekci (existence `/Applications/Cydia.app`, zápis mimo sandbox, dynamické knihovny) a zobrazit varování uživateli. Neblokovat použití — pouze informovat o riziku.
---
## 4. Pozitivní bezpečnostní nálezy
### 4.1 Keychain
Implementace v `KeychainService.swift` je správná:
- Přístupnost: `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly`
- Biometrická ochrana: `.biometryAny` (Face ID / Touch ID)
- Credentials nejsou exportovatelné na jiná zařízení
- Správné error handling pro biometrické selhání
### 4.2 File protection
Všechny soubory na disku používají `.completeFileProtection` — jsou šifrované iOS file protection a přístupné pouze když je zařízení odemčené.
### 4.3 Šifrování lokálních dat
Všechny persistentní soubory (sessions, sender keys, message cache, conversation cache, avatary, TOFU registry) jsou šifrované AES-256-GCM s klíčem derivovaným přes HKDF z identity private key.
### 4.4 Žádné externí závislosti
Aplikace nepoužívá žádné third-party knihovny (CocoaPods, SPM, Carthage). Veškerá kryptografie běží přes nativní Apple frameworky (CryptoKit, Security, CommonCrypto). To eliminuje supply chain rizika.
### 4.5 Žádné WebView
Celé UI je nativní SwiftUI/UIKit. Absence WebView eliminuje kategorii XSS a JavaScript injection zranitelností.
### 4.6 Brute-force ochrana
Login implementuje exponenciální backoff (2^n sekund, max 300s) při neúspěšných pokusech. Server může vyžadovat PoW challenge při registračních surge.
### 4.7 Bezpečná registrace
Registrační flow zahrnuje email verifikaci a volitelný SHA-256 Proof-of-Work challenge jako ochranu proti automatizovaným registracím.
### 4.8 Actor isolation
`ChatClient` je implementován jako Swift actor, což garantuje thread-safe přístup ke kryptografickému stavu bez možnosti race conditions.
---
## 5. Shrnutí změn
| # | Závažnost | Nález | Stav |
|---|---|---|---|
| 3.1.1 | Kritická | Debug výpisy kryptografických klíčů (160×) | **Opraveno** |
| 3.1.2 | Kritická | TLS insecure bypass + volitelné TLS | **Opraveno** |
| 3.2.1 | Vysoká | Heslo zůstává v paměti po loginu | **Opraveno** |
| 3.3.1 | Střední | Clipboard bez auto-clear | **Opraveno** |
| 3.3.2 | Střední | Dev IP adresy v kódu | **Opraveno** |
| 3.4.1 | Střední | Chybějící certificate pinning | Doporučení |
| 3.4.2 | Střední | Chybějící jailbreak detekce | Doporučení |
**Celkové hodnocení po opravách:** Aplikace splňuje vysoké bezpečnostní standardy pro E2EE messaging. Kryptografická architektura je solidní a odpovídá Signal Protocol specifikaci. Zbývající doporučení se týkají defense-in-depth opatření.