Start preview

This commit is contained in:
filip
2026-03-12 19:30:43 +01:00
parent 3204bd6605
commit c73820b9ce
2 changed files with 395 additions and 0 deletions

231
ARCHITECTURE.md Normal file
View File

@@ -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 64B64KB |
| `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<List<Message>> // reactive Room
getMessages(conversationId): List<Message> // suspend
getMessage(messageId): Message? // suspend
getPinnedMessages(conversationId): List<Message> // suspend
searchMessages(conversationId, query): List<Message> // suspend, full-text
insertMessage(message) // suspend
insertMessages(messages) // suspend
markDeleted(messageId) // suspend, soft-delete
updateReactions(messageId, reactions: List<MessageReaction>)
updatePinStatus(messageId, pinnedAt: Date?, pinnedBy: String?)
updateReadBy(messageId, readBy: Set<String>)
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
```

164
TODO.md Normal file
View File

@@ -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 64B64KB
- [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)