diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6066272 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,231 @@ +# Kecalek Android — Architecture Reference + +## Project +- Package: `com.kecalek.chat` +- Root: `E:\encrypted_chat_vyvoj\android\` +- Server: `chat.ai-tech.news:9999` (TCP, newline-delimited JSON) +- iOS ref: `E:\encrypted_chat_vyvoj\ios\Kecalek\Kecalek\` +- Python ref: `E:\encrypted_chat_vyvoj\python\encrypted_chat\` + +## Tech Stack +- Kotlin, Jetpack Compose + Material 3, Catppuccin Mocha dark theme +- Crypto: Tink + Bouncy Castle (Ed25519, X25519, AES-256-GCM, HKDF, RSA-PSS) +- DB: Room + SQLCipher (`kecalek_chat.db`), DI: Hilt, Network: Raw TCP +- Coroutines + Flow throughout + +## Critical Constants +```kotlin +// Constants.kt +SELF_DEVICE_ID = "00000000-0000-0000-0000-000000000000" // self-copy sentinel +DEFAULT_HOST = "chat.ai-tech.news" +DEFAULT_PORT = 9999 +``` + +## Protocol Field Names (MUST match server exactly) +| Field | Correct | Wrong | +|-------|---------|-------| +| X3DH identity key | `"ik"` | ~~`"ik_pub"`~~ | +| X3DH ephemeral key | `"ek"` | ~~`"ek_pub"`~~ | +| Push sender field | `"sender_id"` | ~~`"sender_user_id"`~~ | +| Ratchet header | `{dh_pub: hex, n: int, pn: int}` | — | +| Self-copy ratchet | `{"self": true}` | — | +| Multi-device push | `"device_entries"` array | — | + +## Encryption Protocol +``` +X3DH (new session): + 4 DH ops, HKDF salt=0x00*32, info="EncryptedChat_X3DH" + Header sent: {"ik": b64(idPub), "ek": b64(ephPub), "opk_id": id?} + +Double Ratchet (per-message): + Chain KDF: msg_key=HMAC(ck, 0x01), next_ck=HMAC(ck, 0x02) + encrypt() -> RatchetMessage(header, ciphertext, nonce) + header.toMap() -> {"dh_pub": hex, "n": int, "pn": int} + +AES-256-GCM: 12-byte nonce, 16-byte tag +MessagePadding: pad/unpad with 64B..64KB buckets +Self-copy: HKDF(identityPrivate, "self_encryption") -> static AES key + SELF_DEVICE_ID = "00000000-0000-0000-0000-000000000000" + +Plaintext payload JSON: + {"sender": "username", "text": "hello", "reply_to": null, "timestamp": "2026-...Z"} +Control message: {"_sender_key": {"conv_id": "...", "key": b64}} → import silently +``` + +## Key Files — ALL DONE +| File | Purpose | +|------|---------| +| `crypto/AesGcm.kt` | AES-256-GCM encrypt/decrypt | +| `crypto/Hkdf.kt` | HKDF key derivation | +| `crypto/ECP1.kt` / `KeyEncryption.kt` | Password-based key encryption (magic+salt+nonce+ct) | +| `crypto/Ed25519Crypto.kt` | Ed25519 sign/verify, serialize/deserialize | +| `crypto/X25519Crypto.kt` | X25519 DH, Ed25519→X25519 conversion | +| `crypto/RSACrypto.kt` | RSA-PSS for server login signature | +| `crypto/MessagePadding.kt` | Bucketed padding 64B–64KB | +| `crypto/X3DH.kt` | X3DH initiate (client) + respond (server) | +| `crypto/DoubleRatchet.kt` | Double Ratchet encrypt/decrypt/import/export | +| `crypto/SenderKey.kt` | Group sender key: AAD = chain_id(32B)+n(4B big-endian) | +| `crypto/ContactVerification.kt` | Safety numbers: SHA-512 × 5200, 60 digits (12×5) | +| `network/ConnectionManager.kt` | Raw TCP connection, newline-delimited JSON | +| `network/ProtocolHandler.kt` | Request/response + push notification dispatch | +| `network/ServerApi.kt` | 50 API endpoints | +| `core/SessionManager.kt` | Login/register, session persistence | +| `core/KeyStorage.kt` | Encrypted key persistence (SharedPreferences + ECP1) | +| `core/ChatClient.kt` | Main messaging engine | +| `core/NotificationRouter.kt` | Routes 18 push notification types | +| `data/repository/MessageRepository.kt` | Message CRUD + search + reactions | +| `data/repository/ConversationRepository.kt` | Conversation CRUD | +| `ui/chat/ChatViewModel.kt` | Chat screen ViewModel | +| `ui/conversations/ConversationListViewModel.kt` | Conversation list ViewModel | +| `ui/auth/AuthViewModel.kt` | Auth ViewModel | +| `di/AppModule.kt` | AppDatabase singleton (SQLCipher) | +| `di/DatabaseModule.kt` | DAO providers | +| `di/NetworkModule.kt` | Placeholder (net classes auto-wired) | +| `di/CryptoModule.kt` | Placeholder (crypto = Kotlin objects) | + +## DI Architecture +- All `@Singleton @Inject constructor` classes: auto-wired by Hilt (no @Provides needed) + - `ChatClient`, `ServerApi`, `ConnectionManager`, `SessionManager`, `KeyStorage`, + `NotificationRouter`, `MessageRepository`, `ConversationRepository` +- `AppModule`: provides `AppDatabase` (SQLCipher with passphrase from KeyStorage) +- `DatabaseModule`: provides DAOs (`MessageDao`, `ConversationDao`, `UserCacheDao`) + +## ChatClient — Core Flow +``` +initialize(password): + ECP1.decrypt(encryptedIdentityKey, password) -> identityPriv/Pub + Ed25519→X25519 conversion for DH + selfEncryptionKey = HKDF(identityPriv bytes, "self_encryption") + ensurePrekeys() → upload signed prekey + one-time prekeys if low + setupNotificationHandlers() → register all push handlers + +sendDm(conversationId, plaintext, memberUserIds): + padded = MessagePadding.pad(plaintext) + for each memberId != self: + bundles = getDeviceBundles(memberId) // api.getKeyBundle() + for each bundle: + loadOrCreateSessionWithHeader(memberId, bundle) + → if no session: X3DH.initiate() + build x3dhHeader {ik, ek, opk_id?} + → if existing: load ratchet from keyStorage + ratchetMsg = ratchet.encrypt(padded) + save session + build recipient entry {user_id, device_id, encrypted_content, nonce, + ratchet_header, x3dh_header?} + self-copy: encryptSelf(padded) + SELF_DEVICE_ID + {ratchet_header: {self: true}} + api.sendMessage(conversationId, topLevelHeader, recipients=[...]) + +handleNewMessage(data): // triggered by new_message push + pick entry from device_entries matching myDeviceId or SELF_DEVICE_ID + if ratchet_header.self == true: decryptSelf() + else: decryptDm(senderId, senderDeviceId, ct, nonce, ratchetHeader, x3dhHeader?) + parse JSON payload + if _sender_key: importSenderKey(), return + emit DecryptedMessage on newMessageFlow +``` + +## ChatViewModel — Core Flow +``` +init: + loadConversationInfo() → api.listConversations() → find conv → set members + loadMessages() → api.getMessages() → for each: decryptServerMessage() → Room insert + collect chatClient.newMessageFlow → insert to Room → UI updates via Room Flow + collect messageRepository.getMessagesFlow() → _uiState.messages + +sendMessage(text): + Build JSON {sender, text, reply_to, timestamp} + chatClient.sendDm(conversationId, payload, memberUserIds) + messageRepository.insertMessage(sent msg) → immediate local display + +decryptServerMessage(msgObj): + Handle device_entries OR flat fields (same as handleNewMessage logic) + if self-copy: decryptSelf(); else: decryptDm() + Parse JSON payload → Message domain object +``` + +## Data Classes +```kotlin +Session(userId, username, email, deviceId, serverVersion) +DecryptedMessage(messageId, conversationId, senderId, senderUsername, text?, replyTo?, timestamp) +Message(id, conversationId, senderId, senderUsername, createdAt, text?, replyTo?, + imageFileId?, file?, image?, isDeleted, readBy, reactions, forwardedFrom?, pinnedAt?, pinnedBy?) +MessageReaction(userId, reaction, createdAt) +ConversationMember(userId, username, email) +``` + +## MessageRepository Methods +```kotlin +getMessagesFlow(conversationId): Flow> // reactive Room +getMessages(conversationId): List // suspend +getMessage(messageId): Message? // suspend +getPinnedMessages(conversationId): List // suspend +searchMessages(conversationId, query): List // suspend, full-text +insertMessage(message) // suspend +insertMessages(messages) // suspend +markDeleted(messageId) // suspend, soft-delete +updateReactions(messageId, reactions: List) +updatePinStatus(messageId, pinnedAt: Date?, pinnedBy: String?) +updateReadBy(messageId, readBy: Set) +deleteByConversation(conversationId) +``` + +## ServerApi Key Endpoints +```kotlin +// Messaging +sendMessage(conversationId, ratchetHeader, recipients, x3dhHeader?, imageFileId?) +getMessages(conversationId, limit=50, offset=0, afterTs?) +markRead(conversationId, messageIds) +markConversationRead(conversationId) +deleteMessage(messageId) +reactMessage(messageId, reaction, action="add"/"remove") +pinMessage(messageId, conversationId, action="pin"/"unpin") +getPinnedMessages(conversationId) + +// Keys +getKeyBundle(userId) // returns device bundles for user +uploadPrekeys(signedPrekey, oneTimePrekeys?) +getPrekeyCount() + +// Conversations +listConversations() +createConversation(members, name?) +findConversation(email) +deleteConversation(conversationId) +addMember(conversationId, email) +removeMember(conversationId, userId) +leaveGroup(conversationId) + +// Invitations +listInvitations() +acceptInvitation(conversationId) +declineInvitation(conversationId) + +// Files +uploadImageStart(conversationId, fileId, fileSize, fileType) +uploadImageChunk(fileId, dataBase64) +uploadImageEnd(fileId) +downloadImage(fileId, offset=0) + +// Session +sessionReset(peerUserId, peerDeviceId?) +``` + +## NotificationRouter — 18 Push Types +```kotlin +NEW_MESSAGE, MESSAGES_READ, MESSAGE_DELETED, MESSAGE_DELIVERED, +CONVERSATION_CREATED, MEMBER_ADDED, MEMBER_REMOVED, GROUP_INVITATION, +CONVERSATION_RENAMED, SESSION_RESET, MESSAGE_REACTED, MESSAGE_PINNED, +MESSAGE_UNPINNED, USER_ONLINE, USER_OFFLINE, ONLINE_USERS, +USERNAME_CHANGED, PROTOCOL_ERROR +``` + +## ECP1 Key Format +``` +magic(4B "ECP1") + salt(16B) + nonce(12B) + ciphertext+tag +PBKDF2: 600k iterations, SHA-256, AAD = magic bytes +``` + +## Ed25519 → X25519 Conversion +``` +X25519 private: SHA-512(ed25519_seed)[0:32] clamped (RFC 7748) +X25519 public: Montgomery u = (1+y)/(1-y) mod p +``` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..97fde86 --- /dev/null +++ b/TODO.md @@ -0,0 +1,164 @@ +# Kecalek Android — TODO & Implementation Status + +## ✅ DONE — Infrastructure (Phase 0-3) + +### Phase 0 — Gradle + Base +- [x] Gradle setup (Tink, Bouncy Castle, Room/SQLCipher, Hilt, Compose, OkHttp) +- [x] Catppuccin Mocha dark theme + Material 3 +- [x] Navigation (NavHost, routes) +- [x] Domain models (Message, Conversation, User, MessageReaction, etc.) +- [x] Room entities + DAOs (MessageDao, ConversationDao, UserCacheDao) +- [x] AppDatabase with SQLCipher + +### Phase 1 — Crypto (11 files) +- [x] AesGcm.kt — AES-256-GCM +- [x] Hkdf.kt — HKDF +- [x] ECP1.kt / KeyEncryption.kt — password-based key encryption +- [x] Ed25519Crypto.kt — Ed25519 sign/verify + Ed→X25519 conversion +- [x] X25519Crypto.kt — X25519 DH +- [x] RSACrypto.kt — RSA-PSS for login +- [x] MessagePadding.kt — bucketed padding 64B–64KB +- [x] X3DH.kt — X3DH initiate/respond +- [x] DoubleRatchet.kt — Double Ratchet encrypt/decrypt/import/export +- [x] SenderKey.kt — group sender key protocol +- [x] ContactVerification.kt — safety numbers (SHA-512 × 5200, 60 digits) + +### Phase 2 — Network (3 files) +- [x] ConnectionManager.kt — raw TCP, newline-delimited JSON +- [x] ProtocolHandler.kt — request/response + push dispatch +- [x] ServerApi.kt — 50 endpoints + +### Phase 3 — Core + Repos + DI +- [x] SessionManager.kt — login/register/session persistence +- [x] KeyStorage.kt — encrypted key persistence +- [x] ChatClient.kt — messaging engine (sendDm, decrypt, new_message handler) +- [x] NotificationRouter.kt — routes 18 push types +- [x] MessageRepository.kt — message CRUD + search + reactions +- [x] ConversationRepository.kt — conversation CRUD +- [x] AppModule.kt — AppDatabase singleton +- [x] DatabaseModule.kt — DAO providers +- [x] NetworkModule.kt — placeholder (net classes auto-wired) +- [x] CryptoModule.kt — placeholder (crypto = Kotlin objects) + +### Phase 4 — Service + Feature UI +- [x] ChatPushService.kt (FCM/push forwarding to NotificationRouter) +- [x] AuthViewModel.kt +- [x] ConversationListViewModel.kt +- [x] ChatViewModel.kt — core (loadMessages, sendMessage, incoming flow) +- [x] ChatScreen.kt +- [x] ConversationListScreen.kt +- [x] Auth screens (login, register) + +--- + +## 🔧 READY TO BUILD & TEST + +### Build +- [ ] Build in Android Studio (Ctrl+F9 / Build > Make Project) + - Note: Cannot build from CLI (JAVA_HOME not set on Windows) + - Expected: clean build with no compilation errors + +### Integration Tests +- [ ] Send DM: type message on Android → verify arrives on Python/iOS client +- [ ] Receive DM: send from Python/iOS → verify appears on Android +- [ ] Self-copy: message I send appears in my own chat view immediately +- [ ] X3DH new session: first message to new user triggers X3DH + works correctly +- [ ] Group message: send in group conversation → all members receive +- [ ] Reaction: add emoji reaction → reflects on all clients +- [ ] Pin: pin a message → shows in pinned list +- [ ] Delete: delete message → removed from all clients + +--- + +## 🟡 TODO — ChatViewModel Secondary Features + +### Simple API calls (ready to implement) +- [x] deleteMessage(messageId) — `api.deleteMessage()` + `messageRepository.markDeleted()` +- [x] reactToMessage(messageId, reaction) — `api.reactMessage()` + `messageRepository.updateReactions()` +- [x] pinMessage(messageId) — `api.pinMessage()` + `messageRepository.updatePinStatus()` +- [x] markAsRead() — `api.markConversationRead(conversationId)` +- [x] search(query) — `messageRepository.searchMessages()` + update searchResults in state +- [x] nextSearchResult() / prevSearchResult() — navigate currentSearchIndex +- [x] forwardMessage(messageId, targetConversationId) — re-encrypt plaintext for target conv + +### Complex (need encryption pipeline) +- [ ] sendImage(uri) — AES encrypt image → chunked upload via uploadImageStart/Chunk/End + - ImageInfo {fileId, aesKey, iv, thumbnail, filename, size} stored in message + - Payload: {"sender", "text", "image": {"file_id": ..., "key": b64, "iv": b64}, "timestamp"} +- [ ] sendFile(uri) — similar to sendImage but with FileInfo +- [ ] downloadFile(fileId) — downloadImage() chunks → reassemble → AES decrypt + - Use offset pagination: loop downloadImage(fileId, offset) until complete + +--- + +## 🟡 TODO — ChatClient Group Messaging + +- [ ] sendGroupMessage() — sender key protocol + - Check if each member has received sender key + - If not: send `_sender_key` control message first (DM encrypted per device) + - Then encrypt group message with SenderKey.encrypt() + - Track which members have the key in keyStorage or DB + +- [ ] Group member add: resend sender key to new member via DM +- [ ] Group member remove: rotate sender key, resend to remaining members +- [ ] Handle `member_added` / `member_removed` push notifications in ChatClient + +--- + +## 🟠 KNOWN ISSUES + +### ✅ FIXED: SQLCipher passphrase (AppModule.kt) +- **Was**: hardcoded `"TODO_REPLACE_WITH_DERIVED_KEY"` passphrase +- **Now**: random 32-byte key generated once, stored in EncryptedSharedPreferences (Android Keystore) +- **Migration**: if old DB exists without stored passphrase → old DB deleted, new one created +- Note: Could use HKDF-derived key from identity private (like Python), but Android Keystore approach is simpler and equally secure + +### ✅ FIXED: AuthUiState.useTls = true (registration bug) +- **Was**: `AuthUiState.useTls = true` → registration always tried TLS → SSL error if server is plain TCP +- **Now**: `AuthUiState.useTls = false` — consistent with LoginScreen local default +- LoginScreen always calls `updateServerConfig(useTls=false)` before login anyway +- If server requires TLS, user can toggle it in Server Configuration section on LoginScreen + +### Push notification handler registration +- ChatClient.setupNotificationHandlers() is called from initialize() +- If ChatClient is not initialized (user not logged in), push notifications are silently dropped +- Fix: Check session state before trying to decrypt; store encrypted push and decrypt on next init + +### Session persistence +- Sessions (DoubleRatchet state) are saved to KeyStorage per device +- If app is reinstalled, all sessions are lost → first message after reinstall needs X3DH +- This is expected behavior but worth testing + +--- + +## 📋 PROTOCOL COMPATIBILITY CHECKLIST + +### sendDm() +- [x] Recipients per-device (not per-user) +- [x] Self-copy: device_id = SELF_DEVICE_ID, ratchet_header = {"self": true} +- [x] X3DH header fields: "ik", "ek", "opk_id" (NOT "ik_pub", "ek_pub") +- [x] Binary encoding: base64 via encodeBinary()/decodeBinary() +- [x] Ratchet header: dh_pub (hex), n, pn via header.toMap() +- [x] Plaintext payload: JSON {"sender", "text", "reply_to", "timestamp"} +- [x] Message padding: MessagePadding.pad() before encrypt, unpad() after decrypt + +### handleNewMessage() / decryptServerMessage() +- [x] Push field: "sender_id" (NOT "sender_user_id") +- [x] device_entries array: pick by myDeviceId or SELF_DEVICE_ID +- [x] Self-copy detection: ratchet_header.self == true +- [x] Control messages (_sender_key): import silently, don't display +- [x] Flat fallback fields for backward compat + +--- + +## 🔮 FUTURE — Nice to Have + +- [ ] Voice messages — record, encrypt with AES, upload as file +- [ ] Disappearing messages — server-side TTL +- [ ] Message editing — re-encrypt + server update +- [ ] Key rotation — rotate RSA/Ed25519 identity keys +- [ ] Device pairing — link second device (pairingStart/pairingSend) +- [ ] Contact verification — UI to display safety numbers +- [ ] Backup / restore — export encrypted sessions +- [ ] Push notification channels — Android notification priorities +- [ ] App lock (biometric / PIN)