diff --git a/README.md b/README.md index 6668072..f0bf6d5 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,11 @@ - Есть foreground service, Room-хранилище, ACK/retry очередь и UI в стиле Telegram. - Реализованы главный экран со списком чатов, отдельный экран диалога, меню `три точки -> Настройки`, ручная проверка обновлений и опциональная автопроверка через `version.json`. - В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`. -- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`. -- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`. +- Добавлены профили пользователей: `firstName`, `lastName`, `username`, описание, локальное редактирование профиля и поиск профиля по `username`, имени, фамилии, полному имени и `peerId`. +- Профили распространяются как mesh-пакеты и кэшируются узлами локально; по найденному `username` можно получить `peerId`, и наоборот по `peerId` в UI показывается связанный `@username`, если claim уже известен. +- Username оформлен как подписанный lease-claim на 14 дней: если владелец не появляется в сети 2 недели, username снова становится свободным. +- Добавлен recovery bundle для переноса профиля и права на username на новый телефон даже без доступа к старому устройству. +- Добавлена защита от захвата чужого `username`: активный claim другого владельца не принимается, а смена `peerId` без подписи владельца не даёт забрать профиль. - В настройки добавлены диагностические режимы: карта сети и журнал исходящих, входящих и транзитных пакетов. - Обновление приложения выполняется через APK во временном каталоге: проверка версии, скачивание, остановка mesh и запуск системной установки через `Intent`. - При выключенном Bluetooth приложение запрашивает его включение перед запуском mesh. @@ -88,6 +91,10 @@ - [x] Подключить Room и базовую схему хранения. - [x] Реализовать базовую регистрацию пользователя (локальный профиль). - [x] Добавить кэш профилей из mesh-сети и поиск по `username`. +- [x] Добавить поиск профилей по имени, фамилии, полному имени и `peerId`. +- [x] Добавить lease-механику для `username` с автоматическим освобождением через 14 дней неактивности. +- [x] Добавить перенос профиля на новый телефон через recovery bundle. +- [x] Добавить защиту от захвата чужого `username` и подмены `peerId` в профиле. - [x] Добавить журнал исходящих, входящих и транзитных пакетов. - [x] Добавить режим карты сети в настройках. - [x] Добавить логирование сети и debug-экран маршрутов. @@ -123,4 +130,5 @@ - Все узлы равноправны на уровне текущей архитектуры: каждое устройство может обнаруживать соседей, принимать и ретранслировать пакеты. - Количество пользователей не бесконечно. Практический предел зависит от плотности устройств, качества BLE-эфира, числа одновременных соединений, частоты ретрансляции и ограничений батареи Android. - Каталог профилей хранится распределённо: каждый узел кэширует увиденные профильные пакеты, поэтому поиск по `username` зависит от того, успел ли профиль распространиться по mesh. +- Право на `username` определяется не текущим `peerId`, а подписанным claim владельца. Это защищает имя от простого копирования `peerId` другим устройством. - Для школы такая схема подходит как офлайн-сеть без интернета, но для больших нагрузок всё равно понадобятся дополнительные оптимизации маршрутизации, дедупликации и доставки. diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt index 512238c..08de451 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/ChatActivity.kt @@ -86,6 +86,7 @@ class ChatActivity : AppCompatActivity() { val database = MeshDatabase.getInstance(applicationContext) repository = MeshRepository( + applicationContext, database.messageDao(), database.outboundQueueDao(), database.profileDao(), diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt index 98869e8..89391e3 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/MainActivity.kt @@ -32,6 +32,7 @@ import pro.nnnteam.nnnet.mesh.MeshServiceContract import pro.nnnteam.nnnet.ui.ChatListAdapter import pro.nnnteam.nnnet.ui.ChatListItem import pro.nnnteam.nnnet.update.UpdateInfo +import pro.nnnteam.nnnet.update.UpdateInstaller import pro.nnnteam.nnnet.update.UpdateManager import java.util.Locale @@ -95,6 +96,7 @@ class MainActivity : AppCompatActivity() { val database = MeshDatabase.getInstance(applicationContext) repository = MeshRepository( + applicationContext, database.messageDao(), database.outboundQueueDao(), database.profileDao(), @@ -216,11 +218,12 @@ class MainActivity : AppCompatActivity() { } private suspend fun resolvePeerId(value: String): String? { - val normalized = value.trim().removePrefix("@").lowercase(Locale.getDefault()) - return when { - ':' in value -> value.trim() - else -> repository.profileByUsername(normalized)?.peerId?.takeIf { it.isNotBlank() } - } + val query = value.trim() + val foundPeerId = repository.searchProfiles(query, limit = 1) + .firstOrNull() + ?.peerId + ?.takeIf { it.isNotBlank() } + return foundPeerId ?: query.takeIf { it.isNotBlank() } } private fun openChat(peerId: String) { @@ -367,8 +370,16 @@ class MainActivity : AppCompatActivity() { } ) .setPositiveButton(R.string.download_update) { _, _ -> - val url = UpdateManager.buildDownloadUrl(updateInfo.apkPath) - startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url))) + lifecycleScope.launch { + val apkFile = withContext(Dispatchers.IO) { + UpdateInstaller.downloadToTempFile(this@MainActivity, updateInfo) + } + if (apkFile == null) { + Toast.makeText(this@MainActivity, R.string.update_download_failed, Toast.LENGTH_SHORT).show() + } else { + UpdateInstaller.installDownloadedApk(this@MainActivity, apkFile) + } + } } .setNegativeButton(R.string.later, null) .show() diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/PacketLogActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/PacketLogActivity.kt index 561bc75..dd731b8 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/PacketLogActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/PacketLogActivity.kt @@ -23,6 +23,7 @@ class PacketLogActivity : AppCompatActivity() { val database = MeshDatabase.getInstance(applicationContext) repository = MeshRepository( + applicationContext, database.messageDao(), database.outboundQueueDao(), database.profileDao(), diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/PacketMapActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/PacketMapActivity.kt index b9ac51d..e014b4a 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/PacketMapActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/PacketMapActivity.kt @@ -22,6 +22,7 @@ class PacketMapActivity : AppCompatActivity() { val database = MeshDatabase.getInstance(applicationContext) repository = MeshRepository( + applicationContext, database.messageDao(), database.outboundQueueDao(), database.profileDao(), diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt index 1a6ebbe..7013728 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/SettingsActivity.kt @@ -1,12 +1,16 @@ package pro.nnnteam.nnnet +import android.content.ClipData +import android.content.ClipboardManager import android.os.Build import android.os.Bundle import android.view.View import android.widget.EditText import android.widget.ImageButton +import android.widget.ListView import android.widget.TextView import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton @@ -14,11 +18,14 @@ import com.google.android.material.switchmaterial.SwitchMaterial import kotlinx.coroutines.launch import pro.nnnteam.nnnet.data.MeshDatabase import pro.nnnteam.nnnet.data.MeshRepository +import pro.nnnteam.nnnet.data.ProfileSaveResult import pro.nnnteam.nnnet.data.ProfileEntity import pro.nnnteam.nnnet.mesh.MeshServiceContract import pro.nnnteam.nnnet.update.UpdateInfo import pro.nnnteam.nnnet.update.UpdateInstaller import pro.nnnteam.nnnet.update.UpdateManager +import pro.nnnteam.nnnet.ui.ChatListItem +import pro.nnnteam.nnnet.ui.ChatListAdapter import android.content.BroadcastReceiver import android.content.Intent import android.content.IntentFilter @@ -36,6 +43,11 @@ class SettingsActivity : AppCompatActivity() { private lateinit var resultDescriptionText: TextView private lateinit var resultPeerIdText: TextView private lateinit var updateProgressText: TextView + private lateinit var recoveryInput: EditText + private lateinit var searchResultsView: ListView + private val searchProfileResults = mutableListOf() + private val searchItems = mutableListOf() + private lateinit var searchAdapter: ChatListAdapter private var receiverRegistered = false @@ -50,7 +62,7 @@ class SettingsActivity : AppCompatActivity() { if (eventType == MeshServiceContract.EVENT_PROFILES_CHANGED) { val query = searchInput.text.toString().trim() if (query.isNotEmpty()) { - lookupProfile(query) + searchProfiles(query) } } } @@ -62,6 +74,7 @@ class SettingsActivity : AppCompatActivity() { val database = MeshDatabase.getInstance(applicationContext) repository = MeshRepository( + applicationContext, database.messageDao(), database.outboundQueueDao(), database.profileDao(), @@ -81,6 +94,13 @@ class SettingsActivity : AppCompatActivity() { resultDescriptionText = findViewById(R.id.resultDescriptionText) resultPeerIdText = findViewById(R.id.resultPeerIdText) updateProgressText = findViewById(R.id.updateProgressText) + recoveryInput = findViewById(R.id.recoveryInput) + searchResultsView = findViewById(R.id.searchResultsView) + searchAdapter = ChatListAdapter(this, searchItems) + searchResultsView.adapter = searchAdapter + searchResultsView.setOnItemClickListener { _, _, position, _ -> + renderSearchResult(searchProfileResults.getOrNull(position)) + } val autoUpdateSwitch = findViewById(R.id.autoUpdateSwitch) val versionText = findViewById(R.id.versionText) @@ -101,9 +121,15 @@ class SettingsActivity : AppCompatActivity() { if (query.isEmpty()) { Toast.makeText(this, R.string.enter_username_to_search, Toast.LENGTH_SHORT).show() } else { - lookupProfile(query) + searchProfiles(query) } } + findViewById(R.id.showRecoveryButton).setOnClickListener { + showRecoveryBundle() + } + findViewById(R.id.importRecoveryButton).setOnClickListener { + importRecoveryBundle() + } findViewById(R.id.openMapButton).setOnClickListener { startActivity(Intent(this, PacketMapActivity::class.java)) } @@ -164,26 +190,98 @@ class SettingsActivity : AppCompatActivity() { } lifecycleScope.launch { - repository.saveLocalProfile( + when (val result = repository.saveLocalProfile( firstName = firstName, lastName = lastName, username = username, description = description - ) - Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show() + )) { + is ProfileSaveResult.Saved -> { + Toast.makeText(this@SettingsActivity, R.string.profile_saved, Toast.LENGTH_SHORT).show() + usernameInput.setText(result.profile.username) + } + is ProfileSaveResult.UsernameOccupied -> { + Toast.makeText(this@SettingsActivity, R.string.username_taken, Toast.LENGTH_LONG).show() + } + ProfileSaveResult.InvalidUsername -> { + Toast.makeText(this@SettingsActivity, R.string.username_invalid, Toast.LENGTH_LONG).show() + } + } } } - private fun lookupProfile(username: String) { + private fun searchProfiles(query: String) { lifecycleScope.launch { - val profile = repository.profileByUsername(username.removePrefix("@")) - renderSearchResult(profile) - if (profile == null) { + val profiles = repository.searchProfiles(query) + searchProfileResults.clear() + searchProfileResults.addAll(profiles) + searchItems.clear() + searchItems.addAll( + profiles.map { profile -> + ChatListItem( + peerId = profile.peerId, + title = profile.displayName(), + subtitle = buildString { + append("@${profile.username}") + if (profile.description.isNotBlank()) { + append(" · ${profile.description}") + } else if (profile.peerId.isNotBlank()) { + append(" · ${profile.peerId}") + } + }, + lastStatus = "", + lastTimestamp = maxOf(profile.updatedAt, profile.lastSeenAt) + ) + } + ) + searchAdapter.notifyDataSetChanged() + val first = profiles.firstOrNull() + renderSearchResult(first) + if (first == null) { Toast.makeText(this@SettingsActivity, R.string.profile_not_found_locally, Toast.LENGTH_SHORT).show() } } } + private fun showRecoveryBundle() { + val bundle = repository.exportRecoveryBundle() + recoveryInput.setText(bundle) + AlertDialog.Builder(this) + .setTitle(R.string.recovery_dialog_title) + .setMessage(R.string.recovery_dialog_message) + .setView(EditText(this).apply { + setText(bundle) + setTextIsSelectable(true) + isSingleLine = false + minLines = 6 + setPadding(48, 32, 48, 32) + }) + .setPositiveButton(R.string.copy_code) { _, _ -> + val clipboard = getSystemService(ClipboardManager::class.java) + clipboard.setPrimaryClip(ClipData.newPlainText("NNNet Recovery", bundle)) + Toast.makeText(this, R.string.copied, Toast.LENGTH_SHORT).show() + } + .setNegativeButton(R.string.ok, null) + .show() + Toast.makeText(this, R.string.recovery_shown, Toast.LENGTH_SHORT).show() + } + + private fun importRecoveryBundle() { + val bundle = recoveryInput.text.toString().trim() + if (bundle.isBlank()) { + Toast.makeText(this, R.string.recovery_required, Toast.LENGTH_SHORT).show() + return + } + runCatching { + repository.importRecoveryBundle(bundle) + }.onSuccess { + Toast.makeText(this, R.string.recovery_imported, Toast.LENGTH_SHORT).show() + loadLocalProfile() + }.onFailure { + Toast.makeText(this, R.string.recovery_invalid, Toast.LENGTH_LONG).show() + } + } + private fun renderSearchResult(profile: ProfileEntity?) { if (profile == null) { profileResultCard.visibility = android.view.View.GONE diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/IdentityManager.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/IdentityManager.kt new file mode 100644 index 0000000..73cc453 --- /dev/null +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/IdentityManager.kt @@ -0,0 +1,118 @@ +package pro.nnnteam.nnnet.data + +import android.content.Context +import android.util.Base64 +import org.json.JSONObject +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec + +object IdentityManager { + private const val PREFS_NAME = "nnnet_identity" + private const val KEY_PRIVATE_KEY = "private_key_pkcs8" + private const val KEY_PUBLIC_KEY = "public_key_x509" + private const val RECOVERY_PREFIX = "NNNET-RECOVERY-1:" + + fun getOrCreateKeyPair(context: Context): KeyPair { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val privateEncoded = prefs.getString(KEY_PRIVATE_KEY, null) + val publicEncoded = prefs.getString(KEY_PUBLIC_KEY, null) + if (!privateEncoded.isNullOrBlank() && !publicEncoded.isNullOrBlank()) { + return KeyPair(decodePublicKey(publicEncoded), decodePrivateKey(privateEncoded)) + } + + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(256) + val keyPair = generator.generateKeyPair() + prefs.edit() + .putString(KEY_PRIVATE_KEY, encodePrivateKey(keyPair.private)) + .putString(KEY_PUBLIC_KEY, encodePublicKey(keyPair.public)) + .apply() + return keyPair + } + + fun exportRecoveryBundle(context: Context): String { + val keyPair = getOrCreateKeyPair(context) + val payload = JSONObject() + .put("privateKey", encodePrivateKey(keyPair.private)) + .put("publicKey", encodePublicKey(keyPair.public)) + .toString() + return RECOVERY_PREFIX + Base64.encodeToString(payload.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP) + } + + fun importRecoveryBundle(context: Context, bundle: String) { + require(bundle.startsWith(RECOVERY_PREFIX)) { "invalid recovery prefix" } + val rawJson = String( + Base64.decode(bundle.removePrefix(RECOVERY_PREFIX), Base64.NO_WRAP), + StandardCharsets.UTF_8 + ) + val json = JSONObject(rawJson) + val privateEncoded = json.getString("privateKey") + val publicEncoded = json.getString("publicKey") + decodePrivateKey(privateEncoded) + decodePublicKey(publicEncoded) + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit() + .putString(KEY_PRIVATE_KEY, privateEncoded) + .putString(KEY_PUBLIC_KEY, publicEncoded) + .apply() + } + + fun encodePublicKey(publicKey: PublicKey): String = + Base64.encodeToString(publicKey.encoded, Base64.NO_WRAP) + + fun decodePublicKey(encoded: String): PublicKey { + val factory = KeyFactory.getInstance("EC") + return factory.generatePublic(X509EncodedKeySpec(Base64.decode(encoded, Base64.NO_WRAP))) + } + + fun signClaim(privateKey: PrivateKey, canonicalPayload: String): String { + val signature = Signature.getInstance("SHA256withECDSA") + signature.initSign(privateKey) + signature.update(canonicalPayload.toByteArray(StandardCharsets.UTF_8)) + return Base64.encodeToString(signature.sign(), Base64.NO_WRAP) + } + + fun verifyClaim(publicKey: PublicKey, canonicalPayload: String, signatureValue: String): Boolean { + return runCatching { + val signature = Signature.getInstance("SHA256withECDSA") + signature.initVerify(publicKey) + signature.update(canonicalPayload.toByteArray(StandardCharsets.UTF_8)) + signature.verify(Base64.decode(signatureValue, Base64.NO_WRAP)) + }.getOrDefault(false) + } + + fun canonicalProfileClaim( + username: String, + firstName: String, + lastName: String, + description: String, + peerId: String, + updatedAt: Long, + leaseExpiresAt: Long + ): String { + return listOf( + username.trim().lowercase(), + firstName.trim(), + lastName.trim(), + description.trim(), + peerId.trim(), + updatedAt.toString(), + leaseExpiresAt.toString() + ).joinToString("|") + } + + private fun encodePrivateKey(privateKey: PrivateKey): String = + Base64.encodeToString(privateKey.encoded, Base64.NO_WRAP) + + private fun decodePrivateKey(encoded: String): PrivateKey { + val factory = KeyFactory.getInstance("EC") + return factory.generatePrivate(PKCS8EncodedKeySpec(Base64.decode(encoded, Base64.NO_WRAP))) + } +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt index 4ccfa80..f59e839 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshDatabase.kt @@ -7,7 +7,7 @@ import androidx.room.RoomDatabase @Database( entities = [MessageEntity::class, OutboundQueueEntity::class, ProfileEntity::class, PacketTraceEntity::class], - version = 3, + version = 4, exportSchema = false ) abstract class MeshDatabase : RoomDatabase() { diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt index 85a404c..41730af 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/MeshRepository.kt @@ -1,10 +1,12 @@ package pro.nnnteam.nnnet.data +import android.content.Context import pro.nnnteam.nnnet.mesh.MeshPacket import pro.nnnteam.nnnet.mesh.PacketType import java.util.UUID class MeshRepository( + private val context: Context, private val messageDao: MessageDao, private val queueDao: OutboundQueueDao, private val profileDao: ProfileDao, @@ -71,7 +73,7 @@ class MeshRepository( messageId: String, attemptCount: Int, nextAttemptAt: Long, - error: String? = null, + @Suppress("UNUSED_PARAMETER") error: String? = null, now: Long = System.currentTimeMillis() ) { messageDao.updateStatus(messageId, STATUS_SENT, now, null) @@ -106,8 +108,36 @@ class MeshRepository( description: String, peerId: String = "", now: Long = System.currentTimeMillis() - ): ProfileEntity { + ): ProfileSaveResult { val normalizedUsername = normalizeUsername(username) + if (!USERNAME_PATTERN.matches(normalizedUsername)) { + return ProfileSaveResult.InvalidUsername + } + + val localProfile = profileDao.localProfile() + val keyPair = IdentityManager.getOrCreateKeyPair(context) + val publicKey = IdentityManager.encodePublicKey(keyPair.public) + val existingClaim = profileDao.findByUsername(normalizedUsername) + if ( + existingClaim != null && + existingClaim.publicKey != publicKey && + !existingClaim.isExpired(now) + ) { + return ProfileSaveResult.UsernameOccupied(existingClaim) + } + + val leaseExpiresAt = now + USERNAME_LEASE_MS + val canonical = IdentityManager.canonicalProfileClaim( + username = normalizedUsername, + firstName = firstName, + lastName = lastName, + description = description, + peerId = peerId, + updatedAt = now, + leaseExpiresAt = leaseExpiresAt + ) + val signature = IdentityManager.signClaim(keyPair.private, canonical) + profileDao.deleteLocalProfiles() val entity = ProfileEntity( username = normalizedUsername, @@ -116,69 +146,136 @@ class MeshRepository( description = description.trim(), peerId = peerId, updatedAt = now, + leaseExpiresAt = leaseExpiresAt, lastSeenAt = now, + publicKey = publicKey, + signature = signature, isLocal = true ) profileDao.upsert(entity) - return entity + + if (localProfile != null && localProfile.username != normalizedUsername) { + expireIfOwned(localProfile.username, now) + } + + return ProfileSaveResult.Saved(entity) } suspend fun localProfile(): ProfileEntity? = profileDao.localProfile() - suspend fun updateLocalProfilePeerId(peerId: String, now: Long = System.currentTimeMillis()) { - if (peerId.isBlank()) return - profileDao.updateLocalPeerId(peerId, now, now) + suspend fun refreshLocalClaim(peerId: String, now: Long = System.currentTimeMillis()): ProfileEntity? { + if (peerId.isBlank()) return null + val localProfile = profileDao.localProfile() ?: return null + val leaseExpiresAt = now + USERNAME_LEASE_MS + val canonical = IdentityManager.canonicalProfileClaim( + username = localProfile.username, + firstName = localProfile.firstName, + lastName = localProfile.lastName, + description = localProfile.description, + peerId = peerId, + updatedAt = now, + leaseExpiresAt = leaseExpiresAt + ) + val keyPair = IdentityManager.getOrCreateKeyPair(context) + val signature = IdentityManager.signClaim(keyPair.private, canonical) + profileDao.updateLocalClaim(peerId, now, leaseExpiresAt, now, signature) + return profileDao.localProfile() } suspend fun upsertRemoteProfile( payload: ProfilePayload, peerId: String, now: Long = System.currentTimeMillis() - ): ProfileEntity? { + ): RemoteProfileResult { val normalizedUsername = normalizeUsername(payload.username) - if (normalizedUsername.isBlank()) return null + if (normalizedUsername.isBlank()) return RemoteProfileResult.Invalid + if (!USERNAME_PATTERN.matches(normalizedUsername)) return RemoteProfileResult.Invalid + if (payload.leaseExpiresAt < now) return RemoteProfileResult.Expired + + val canonical = IdentityManager.canonicalProfileClaim( + username = normalizedUsername, + firstName = payload.firstName, + lastName = payload.lastName, + description = payload.description, + peerId = peerId, + updatedAt = payload.updatedAt, + leaseExpiresAt = payload.leaseExpiresAt + ) + val publicKey = runCatching { IdentityManager.decodePublicKey(payload.publicKey) }.getOrNull() + ?: return RemoteProfileResult.Invalid + val valid = IdentityManager.verifyClaim(publicKey, canonical, payload.signature) + if (!valid) return RemoteProfileResult.Invalid + val localProfile = profileDao.localProfile() - if (localProfile?.username == normalizedUsername) { - return localProfile + if (localProfile?.username == normalizedUsername && localProfile.publicKey != payload.publicKey) { + return RemoteProfileResult.Rejected(localProfile) } val existing = profileDao.findByUsername(normalizedUsername) + if (existing != null && existing.publicKey != payload.publicKey && !existing.isExpired(now)) { + return RemoteProfileResult.Rejected(existing) + } + + val existingByKey = profileDao.findByPublicKey(payload.publicKey) + if (existingByKey != null && existingByKey.username != normalizedUsername) { + profileDao.upsert(existingByKey.copy(leaseExpiresAt = now - 1)) + } + val entity = ProfileEntity( username = normalizedUsername, firstName = payload.firstName.trim(), lastName = payload.lastName.trim(), description = payload.description.trim(), peerId = peerId, - updatedAt = maxOf(payload.updatedAt, existing?.updatedAt ?: 0L), + updatedAt = payload.updatedAt, + leaseExpiresAt = payload.leaseExpiresAt, lastSeenAt = now, + publicKey = payload.publicKey, + signature = payload.signature, isLocal = false ) profileDao.upsert(entity) - return entity + return RemoteProfileResult.Stored(entity) } - suspend fun profileByUsername(username: String): ProfileEntity? { - return profileDao.findByUsername(normalizeUsername(username)) + suspend fun profileByUsername(username: String, now: Long = System.currentTimeMillis()): ProfileEntity? { + val entity = profileDao.findByUsername(normalizeUsername(username)) ?: return null + return if (entity.isExpired(now)) null else entity } - suspend fun profileByPeerId(peerId: String): ProfileEntity? { + suspend fun profileByPeerId(peerId: String, now: Long = System.currentTimeMillis()): ProfileEntity? { if (peerId.isBlank()) return null - return profileDao.findByPeerId(peerId) + val entity = profileDao.findByPeerId(peerId) ?: return null + return if (entity.isExpired(now)) null else entity } - suspend fun searchProfiles(query: String, limit: Int = 20): List { + suspend fun searchProfiles(query: String, now: Long = System.currentTimeMillis(), limit: Int = 20): List { val trimmed = query.trim() if (trimmed.isBlank()) return emptyList() - val exact = profileByUsername(trimmed) - val fuzzy = profileDao.search("%$trimmed%", limit) - return buildList { + val lookup = trimmed.removePrefix("@").lowercase() + val exact = profileByUsername(lookup, now) + val fuzzy = profileDao.search("%$trimmed%", limit * 2) + .filterNot { it.isExpired(now) } + val combined = buildList { if (exact != null) add(exact) fuzzy.forEach { candidate -> - if (none { it.username == candidate.username }) { + if (this.none { existing -> existing.username == candidate.username && existing.peerId == candidate.peerId }) { add(candidate) } } } + return combined.sortedWith( + compareBy { + val fullName = listOf(it.firstName, it.lastName).joinToString(" ").trim().lowercase() + when { + it.username == lookup -> 0 + it.peerId.equals(trimmed, ignoreCase = true) -> 1 + fullName == trimmed.lowercase() -> 2 + it.firstName.equals(trimmed, ignoreCase = true) || it.lastName.equals(trimmed, ignoreCase = true) -> 3 + else -> 4 + } + }.thenByDescending { it.updatedAt } + ).take(limit) } suspend fun queuedCount(): Int = queueDao.count() @@ -222,23 +319,45 @@ class MeshRepository( } } - suspend fun mapNodes(limit: Int = 24): List { + suspend fun mapNodes(limit: Int = 24, now: Long = System.currentTimeMillis()): List { val peerIds = packetTraceDao.recentPeerIds(limit) return peerIds.mapNotNull { peerId -> - profileDao.findByPeerId(peerId) ?: ProfileEntity( - username = peerId.lowercase(), - firstName = "", - lastName = "", - description = "", - peerId = peerId, - updatedAt = 0L, - lastSeenAt = 0L, - isLocal = false - ) + val existing = profileDao.findByPeerId(peerId) + when { + existing == null -> ProfileEntity( + username = peerId.lowercase(), + firstName = "", + lastName = "", + description = "", + peerId = peerId, + updatedAt = 0L, + leaseExpiresAt = now + USERNAME_LEASE_MS, + lastSeenAt = 0L, + publicKey = "", + signature = "", + isLocal = false + ) + existing.isExpired(now) -> null + else -> existing + } } } - private fun normalizeUsername(value: String): String = value.trim().lowercase() + fun exportRecoveryBundle(): String = IdentityManager.exportRecoveryBundle(context) + + fun importRecoveryBundle(bundle: String) { + IdentityManager.importRecoveryBundle(context, bundle) + } + + private suspend fun expireIfOwned(username: String, now: Long) { + val existing = profileDao.findByUsername(username) ?: return + if (existing.isLocal) return + if (existing.leaseExpiresAt < now) { + profileDao.upsert(existing.copy(leaseExpiresAt = now - 1)) + } + } + + private fun normalizeUsername(value: String): String = value.trim().removePrefix("@").lowercase() private fun directionLabel(direction: String): String = when (direction) { TRACE_OUTGOING -> "Исходящий" @@ -258,7 +377,22 @@ class MeshRepository( const val TRACE_OUTGOING = "outgoing" const val TRACE_INCOMING = "incoming" const val TRACE_RELAY = "relay" + const val USERNAME_LEASE_MS = 14L * 24L * 60L * 60L * 1000L + val USERNAME_PATTERN = Regex("^[a-z0-9_]{4,32}$") private const val DEFAULT_MAX_ATTEMPTS = 5 } } + +sealed interface ProfileSaveResult { + data class Saved(val profile: ProfileEntity) : ProfileSaveResult + data class UsernameOccupied(val profile: ProfileEntity) : ProfileSaveResult + data object InvalidUsername : ProfileSaveResult +} + +sealed interface RemoteProfileResult { + data class Stored(val profile: ProfileEntity) : RemoteProfileResult + data class Rejected(val conflictingProfile: ProfileEntity) : RemoteProfileResult + data object Expired : RemoteProfileResult + data object Invalid : RemoteProfileResult +} diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileDao.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileDao.kt index 9db8e11..cf6b71e 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileDao.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileDao.kt @@ -22,16 +22,43 @@ interface ProfileDao { @Query("SELECT * FROM profiles WHERE peerId = :peerId ORDER BY isLocal DESC, updatedAt DESC LIMIT 1") suspend fun findByPeerId(peerId: String): ProfileEntity? + @Query("SELECT * FROM profiles WHERE publicKey = :publicKey ORDER BY isLocal DESC, updatedAt DESC LIMIT 1") + suspend fun findByPublicKey(publicKey: String): ProfileEntity? + @Query( """ SELECT * FROM profiles - WHERE username LIKE :query OR firstName LIKE :query OR lastName LIKE :query + WHERE username LIKE :query + OR firstName LIKE :query + OR lastName LIKE :query + OR (firstName || ' ' || lastName) LIKE :query + OR (lastName || ' ' || firstName) LIKE :query + OR peerId LIKE :query ORDER BY isLocal DESC, updatedAt DESC LIMIT :limit """ ) suspend fun search(query: String, limit: Int): List - @Query("UPDATE profiles SET peerId = :peerId, updatedAt = :updatedAt, lastSeenAt = :lastSeenAt WHERE isLocal = 1") - suspend fun updateLocalPeerId(peerId: String, updatedAt: Long, lastSeenAt: Long) + @Query( + """ + UPDATE profiles + SET peerId = :peerId, + updatedAt = :updatedAt, + leaseExpiresAt = :leaseExpiresAt, + lastSeenAt = :lastSeenAt, + signature = :signature + WHERE isLocal = 1 + """ + ) + suspend fun updateLocalClaim( + peerId: String, + updatedAt: Long, + leaseExpiresAt: Long, + lastSeenAt: Long, + signature: String + ) + + @Query("SELECT * FROM profiles WHERE isLocal = 0 AND leaseExpiresAt >= :now ORDER BY updatedAt DESC") + suspend fun activeRemoteProfiles(now: Long): List } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileEntity.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileEntity.kt index b3cdb72..a7423a3 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileEntity.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfileEntity.kt @@ -11,7 +11,10 @@ data class ProfileEntity( val description: String, val peerId: String, val updatedAt: Long, + val leaseExpiresAt: Long, val lastSeenAt: Long, + val publicKey: String, + val signature: String, val isLocal: Boolean ) { fun displayName(): String { @@ -23,10 +26,15 @@ data class ProfileEntity( } fun metaLine(): String { - return if (peerId.isBlank()) { - "@$username" - } else { - "@$username · $peerId" + return buildString { + append("@") + append(username) + if (peerId.isNotBlank()) { + append(" · ") + append(peerId) + } } } + + fun isExpired(now: Long = System.currentTimeMillis()): Boolean = !isLocal && leaseExpiresAt < now } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfilePayload.kt b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfilePayload.kt index 9374f2f..fdcff9c 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfilePayload.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/data/ProfilePayload.kt @@ -7,7 +7,10 @@ data class ProfilePayload( val lastName: String, val username: String, val description: String, - val updatedAt: Long + val updatedAt: Long, + val leaseExpiresAt: Long, + val publicKey: String, + val signature: String ) { fun normalizedUsername(): String = username.trim().lowercase() } @@ -20,6 +23,9 @@ object ProfilePayloadCodec { .put("username", payload.username) .put("description", payload.description) .put("updatedAt", payload.updatedAt) + .put("leaseExpiresAt", payload.leaseExpiresAt) + .put("publicKey", payload.publicKey) + .put("signature", payload.signature) .toString() } @@ -30,7 +36,10 @@ object ProfilePayloadCodec { lastName = json.optString("lastName", "").trim(), username = json.getString("username").trim(), description = json.optString("description", "").trim(), - updatedAt = json.optLong("updatedAt", System.currentTimeMillis()) + updatedAt = json.optLong("updatedAt", System.currentTimeMillis()), + leaseExpiresAt = json.optLong("leaseExpiresAt", 0L), + publicKey = json.getString("publicKey"), + signature = json.getString("signature") ) } } diff --git a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt index 7d6dee4..d634b57 100644 --- a/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt +++ b/android/app/src/main/java/pro/nnnteam/nnnet/mesh/MeshForegroundService.kt @@ -32,6 +32,7 @@ class MeshForegroundService : Service() { createNotificationChannel() val database = MeshDatabase.getInstance(applicationContext) repository = MeshRepository( + applicationContext, database.messageDao(), database.outboundQueueDao(), database.profileDao(), @@ -68,10 +69,15 @@ class MeshForegroundService : Service() { onProfileReceived = { packet -> serviceScope.launch { val payload = runCatching { ProfilePayloadCodec.decode(packet.payload) }.getOrNull() ?: return@launch - val stored = repository.upsertRemoteProfile(payload, packet.senderId) - if (stored != null) { - sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${stored.username}") - sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, stored.username) + when (val result = repository.upsertRemoteProfile(payload, packet.senderId)) { + is pro.nnnteam.nnnet.data.RemoteProfileResult.Stored -> { + sendEvent(MeshServiceContract.EVENT_LOG, "Профиль обновлён: @${result.profile.username}") + sendEvent(MeshServiceContract.EVENT_PROFILES_CHANGED, result.profile.username) + } + is pro.nnnteam.nnnet.data.RemoteProfileResult.Rejected -> { + sendEvent(MeshServiceContract.EVENT_LOG, "Отклонён захват username @${result.conflictingProfile.username}") + } + else -> Unit } } }, @@ -131,7 +137,6 @@ class MeshForegroundService : Service() { queueProcessor.start() queueProcessor.poke() serviceScope.launch { - repository.updateLocalProfilePeerId(bleMeshManager.nodeId) publishLocalProfile(force = true) } } @@ -142,14 +147,16 @@ class MeshForegroundService : Service() { return } - repository.updateLocalProfilePeerId(bleMeshManager.nodeId) - val localProfile = repository.localProfile() ?: return + val localProfile = repository.refreshLocalClaim(bleMeshManager.nodeId) ?: return val payload = ProfilePayload( firstName = localProfile.firstName, lastName = localProfile.lastName, username = localProfile.username, description = localProfile.description, - updatedAt = localProfile.updatedAt + updatedAt = localProfile.updatedAt, + leaseExpiresAt = localProfile.leaseExpiresAt, + publicKey = localProfile.publicKey, + signature = localProfile.signature ) val sent = bleMeshManager.sendPacket( MeshPacket( diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 7549e29..a575676 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -137,6 +137,16 @@ android:text="@string/find_profile" app:cornerRadius="18dp" /> + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f0187e2..9ce974f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Без разрешений BLE сеть не запустится Для работы нужен включённый Bluetooth Bluetooth на устройстве недоступен - Введите ID устройства + Введите username, имя или peerId Профиль не найден в локальном каталоге сети Не удалось проверить обновления Не удалось скачать обновление @@ -49,14 +49,29 @@ Описание Сохранить профиль Найти профиль - Введите username + Введите username, имя или peerId Найти - Введите username для поиска + Введите username, имя или peerId для поиска Username обязателен Профиль сохранён + Этот username уже занят активным владельцем + Username должен содержать 4-32 символа: латиница, цифры, _ Описание не указано peerId пока неизвестен peerId: %1$s + Перенос профиля + Показать код переноса + Импортировать код переноса + Код переноса профиля + Код переноса сформирован + Вставьте код переноса + Код переноса импортирован + Код переноса повреждён или неверен + Код переноса + Сохраните этот код в надёжном месте. С его помощью можно вернуть username и профиль на новом телефоне. + ОК + Копировать код + Скопировано Диагностика сети Режим карты Журнал пакетов diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b61839d..c2a97df 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ - Diagnostics Layer: карта сети и журнал пакетов, построенные на данных `Room`. - Delivery Layer: retry queue, ACK timeout, повторные отправки из фонового сервиса. - Update Layer: `version.json`, changelog и ручная/автоматическая проверка обновлений клиента. -- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети и разрешение `username -> peerId`. +- Profile Layer: локальный профиль пользователя, `username` как основной идентификатор, кэш профилей из mesh-сети, разрешение `username <-> peerId`, lease на 14 дней и recovery bundle для переноса идентичности. ## Пользовательский сценарий - Главный экран показывает список чатов в стиле Telegram. @@ -16,7 +16,7 @@ - Слева в шапке показывается общее количество известных устройств в mesh. - В меню `три точки` доступны `Карта сети`, `Пакеты` и `Настройки`, отдельный debug-лог из пользовательского интерфейса убран. - Отправка сообщений доступна только из экрана конкретного диалога. -- В настройках пользователь редактирует свой профиль и ищет другие профили по `username`. +- В настройках пользователь редактирует свой профиль, ищет другие профили по `username`, имени, фамилии, полному имени и `peerId`, а также может экспортировать или импортировать recovery bundle. - В настройках доступны режим карты сети и экран журнала пакетов. - Поток обновления: `version.json` -> скачивание APK в `cache/updates` -> остановка mesh -> запуск системной установки через `FileProvider` и `Intent.ACTION_VIEW`. @@ -25,6 +25,9 @@ - Все узлы равноправны: каждый телефон может быть источником, получателем и ретранслятором. - Сеть не рассчитана на бесконечное число пользователей. Масштаб ограничивается радиусом BLE, количеством соседних соединений, частотой ретрансляции и ограничениями Android по энергии и фону. - Профильные данные передаются отдельными mesh-пакетами и кэшируются на устройствах. Это даёт распределённый каталог пользователей без центрального сервера, но актуальность данных зависит от распространения пакетов по сети. +- Каждый профильный пакет содержит подписанный claim владельца: `username`, имя, описание, `peerId`, `updatedAt`, `leaseExpiresAt`, `publicKey`, `signature`. +- Захват чужого `username` блокируется проверкой подписи и активного lease. Если владелец не появляется в сети 14 дней, claim считается протухшим и `username` можно занять заново. +- Перенос на новый телефон делается через recovery bundle с ключами владельца: после импорта тот же пользователь может опубликовать прежний `username` уже с новым `peerId`. - Карта сети строится как относительная топология связей, а не как GPS/геометрическая карта здания. Высота этажей пока не моделируется. ## Сетевой пакет (черновик) @@ -42,5 +45,5 @@ ## Ближайшие шаги 1. Укрепить transport: фрагментация крупных пакетов и более надёжный reconnect. -2. Ввести шифрование payload и подпись пакетов. +2. Ввести шифрование payload и подпись уже не только профильных, но и message-пакетов. 3. Добавить инструментальные BLE-тесты на нескольких устройствах и полевой прогон. diff --git a/website/index.html b/website/index.html index 0b100fd..d008458 100644 --- a/website/index.html +++ b/website/index.html @@ -44,6 +44,9 @@
BLE-поиск

Обнаружение ближайших узлов и обмен пакетами без интернета.

Mesh-ретрансляция

Передача сообщений hop-by-hop с TTL, ACK и повторными попытками.

Локальное хранение

Room хранит историю сообщений, очередь доставки, кэш профилей и журнал пакетов.

+
Профили и поиск

Поиск по @username, имени, фамилии и peerId. Связка username ↔ peerId хранится в mesh-каталоге.

+
Перенос профиля

Recovery bundle позволяет вернуть профиль и username на новом телефоне даже без старого устройства.

+
Защита username

Профиль подписывается владельцем. Активный username освобождается только после 14 дней неактивности.