diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt index 9d2577164c..38dca35831 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt @@ -14,6 +14,7 @@ import com.flipcash.app.push.PushTokenProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.shared.profile.ProfileCoordinator +import com.flipcash.services.chat.WrongKeyChats import com.flipcash.services.controllers.AccountController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController @@ -60,6 +61,7 @@ class AuthManager @Inject constructor( private val profileCoordinator: ProfileCoordinator, private val contactCoordinator: ContactCoordinator, private val chatCoordinator: ChatCoordinator, + private val wrongKeyChats: WrongKeyChats, private val networkObserver: NetworkConnectivityListener, private val dispatchers: DispatcherProvider, // private val analytics: AnalyticsService, @@ -440,6 +442,7 @@ class AuthManager @Inject constructor( userFlags.clearAll() profileCoordinator.reset() userFlags.resetCache() + wrongKeyChats.reset() if (!BuildConfig.DEBUG) TraceManager.userId = null } diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt index 570f619337..d25e50198e 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt @@ -15,6 +15,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.profile.ProfileCoordinator +import com.flipcash.services.chat.WrongKeyChats import com.flipcash.services.controllers.AccountController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.controllers.PushController @@ -72,6 +73,7 @@ class AuthManagerTest { private val profileCoordinator: ProfileCoordinator = mockk(relaxed = true) private val contactCoordinator: ContactCoordinator = mockk(relaxed = true) private val chatCoordinator: ChatCoordinator = mockk(relaxed = true) + private val wrongKeyChats: WrongKeyChats = mockk(relaxed = true) private val userManagerState = MutableStateFlow(UserManager.State()) private val networkConnectivityListener = mockk(relaxed = true) @@ -122,6 +124,7 @@ class AuthManagerTest { profileCoordinator = profileCoordinator, contactCoordinator = contactCoordinator, chatCoordinator = chatCoordinator, + wrongKeyChats = wrongKeyChats, dispatchers = dispatchers, networkObserver = networkConnectivityListener, ) @@ -258,6 +261,8 @@ class AuthManagerTest { verify { featureFlagController.reset() } verify { appSettings.reset() } verify { userFlags.clearAll() } + // A wrong-key mark is keyed by chat id alone, so the next account must not inherit it. + verify { wrongKeyChats.reset() } } @Test diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/46.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/46.json new file mode 100644 index 0000000000..f8b67dc548 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/46.json @@ -0,0 +1,1062 @@ +{ + "formatVersion": 1, + "database": { + "version": 46, + "identityHash": "e239ef9b85f5c4def303c6a390aea283", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `can_invite` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e239ef9b85f5c4def303c6a390aea283')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 309f74ac3d..2440f82a44 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -126,8 +126,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 42, to = 43), // pending_media table AutoMigration(from = 43, to = 44), // chat_metadata.cover_picture_json (nullable) AutoMigration(from = 44, to = 45), // chat_metadata.is_private (default 0) + AutoMigration(from = 45, to = 46, spec = FlipcashDatabase.Migration45To46::class), // chat_metadata.can_invite (default 0) ], - version = 45, + version = 46, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -296,6 +297,17 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + /** + * Re-arms the viewer-state gate for the new `can_invite` column, for the reason + * [Migration36To37] gives for `can_edit`: without it a host's invite entry points stay hidden + * on every private group created before the upgrade until its viewer state version moves. + */ + class Migration45To46 : AutoMigrationSpec { + override fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL(Migration36To37.REARM_VIEWER_STATE) + } + } + /** * Adds `chat_messages.ciphertext_json` and `encryption_state`. A row stored before this version * with encrypted content was never opened, so it's marked for opening with its ciphertext diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index b050d9356d..7a18b0687a 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -165,7 +165,7 @@ interface ChatMetadataDao { @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " + "mute_forever = :muteForever, viewer_state_version = :version, " + - "can_edit = :canEdit " + + "can_edit = :canEdit, can_invite = :canInvite " + "WHERE chat_id_hex = :chatIdHex AND (:version > viewer_state_version OR viewer_state_version = 0)" ) suspend fun updateViewerStateIfNewer( @@ -173,6 +173,7 @@ interface ChatMetadataDao { muteUntilEpochMs: Long?, muteForever: Boolean, canEdit: Boolean, + canInvite: Boolean, version: Long, ) @@ -189,7 +190,7 @@ interface ChatMetadataDao { */ @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = NULL, mute_forever = 0, " + - "viewer_state_version = 0, can_edit = 0 WHERE chat_id_hex = :chatIdHex" + "viewer_state_version = 0, can_edit = 0, can_invite = 0 WHERE chat_id_hex = :chatIdHex" ) suspend fun clearViewerState(chatIdHex: String) @@ -232,6 +233,7 @@ interface ChatMetadataDao { muteUntilEpochMs = entity.muteUntilEpochMs, muteForever = entity.muteForever, canEdit = entity.canEdit, + canInvite = entity.canInvite, version = entity.viewerStateVersion, ) } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index 882e208ef7..1642daf28d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -65,6 +65,9 @@ data class ChatMetadataEntity( // ViewerState.Permissions — a column no write has reached yet denies rather than grants. @ColumnInfo(name = "can_edit", defaultValue = "0") val canEdit: Boolean = false, + // Private group creator only. Same storage rules as can_edit. + @ColumnInfo(name = "can_invite", defaultValue = "0") + val canInvite: Boolean = false, // Group creator; null for DMs and for group chats reconstructed without a server round // trip. Stored hex-encoded, matching every other user id column in this database. @ColumnInfo(name = "creator_hex") diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/InviteGrantMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/InviteGrantMigrationTest.kt new file mode 100644 index 0000000000..c1190cb3c4 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/InviteGrantMigrationTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.persistence + +import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteConnection +import androidx.test.core.app.ApplicationProvider +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * Covers the v46 upgrade adding `chat_metadata.can_invite`. + * + * Room's auto-migration adds the column at 0. [FlipcashDatabase.Migration45To46] then zeroes every + * `viewer_state_version`, so the next fetch, which reports the version the row already holds, gets + * past the write gate and fills the grant. + */ +@RunWith(RobolectricTestRunner::class) +class InviteGrantMigrationTest { + + private val context = ApplicationProvider.getApplicationContext() + + private fun openV46Shape(): SupportSQLiteDatabase { + val callback = object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + } + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(null) // in-memory + .callback(callback) + .build() + val db = FrameworkSQLiteOpenHelperFactory().create(config).writableDatabase + // The columns the spec touches, as the auto-migration leaves them. + db.execSQL( + "CREATE TABLE chat_metadata (chat_id_hex TEXT NOT NULL, " + + "mute_forever INTEGER NOT NULL DEFAULT 0, " + + "viewer_state_version INTEGER NOT NULL DEFAULT 0, " + + "can_edit INTEGER NOT NULL DEFAULT 0, " + + "can_invite INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(chat_id_hex))" + ) + return db + } + + private fun SupportSQLiteDatabase.long(sql: String): Long = + query(sql).use { it.moveToFirst(); it.getLong(0) } + + @Test + fun `migration 45 to 46 re-arms the viewer state gate and keeps the mute`() { + val db = openV46Shape() + db.execSQL( + "INSERT INTO chat_metadata (chat_id_hex, mute_forever, viewer_state_version, can_edit) VALUES (?,?,?,?)", + arrayOf("aa", 1, 7, 1), + ) + db.execSQL( + "INSERT INTO chat_metadata (chat_id_hex, viewer_state_version) VALUES (?,?)", + arrayOf("bb", 3), + ) + + FlipcashDatabase.Migration45To46().onPostMigrate(SupportSQLiteConnection(db)) + + assertEquals(0L, db.long("SELECT MAX(viewer_state_version) FROM chat_metadata")) + assertEquals(0L, db.long("SELECT SUM(can_invite) FROM chat_metadata")) + // Only the version is reset; state already stored stays until the fetch replaces it. + assertEquals(1L, db.long("SELECT mute_forever FROM chat_metadata WHERE chat_id_hex = 'aa'")) + assertEquals(1L, db.long("SELECT can_edit FROM chat_metadata WHERE chat_id_hex = 'aa'")) + + db.close() + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 9b690d83c4..782befc9e6 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -65,6 +65,7 @@ class ChatMetadataDaoTest { muteForever: Boolean = false, viewerStateVersion: Long = 0, canEdit: Boolean = false, + canInvite: Boolean = false, useE2ee: Boolean = false, isPrivate: Boolean = false, ) = ChatMetadataEntity( @@ -88,6 +89,7 @@ class ChatMetadataDaoTest { muteForever = muteForever, viewerStateVersion = viewerStateVersion, canEdit = canEdit, + canInvite = canInvite, useE2ee = useE2ee, isPrivate = isPrivate, ) @@ -351,6 +353,7 @@ class ChatMetadataDaoTest { muteUntilEpochMs = null, muteForever = false, canEdit = false, + canInvite = false, version = 5, ) @@ -368,10 +371,10 @@ class ChatMetadataDaoTest { @Test fun `an older viewer state version leaves the mute alone`() = runTest { dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, version = 5) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, canInvite = false, version = 5) // The mute this supersedes, arriving late. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, canEdit = false, version = 4) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, canEdit = false, canInvite = false, version = 4) val stored = requireNotNull(dao.getById(CHAT_HEX)) assertEquals(true, stored.muteForever) @@ -394,6 +397,7 @@ class ChatMetadataDaoTest { muteUntilEpochMs = null, muteForever = false, canEdit = true, + canInvite = false, version = 0, ) @@ -407,9 +411,9 @@ class ChatMetadataDaoTest { @Test fun `a viewer state at version zero cannot overwrite a real one`() = runTest { dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 3) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = false, version = 3) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 0) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, canInvite = false, version = 0) val stored = requireNotNull(dao.getById(CHAT_HEX)) assertEquals(true, stored.canEdit) @@ -433,7 +437,7 @@ class ChatMetadataDaoTest { fun `switching to an indefinite mute clears the deadline`() = runTest { dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = 5_000, viewerStateVersion = 1)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, version = 2) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, canEdit = false, canInvite = false, version = 2) val stored = requireNotNull(dao.getById(CHAT_HEX)) assertNull(stored.muteUntilEpochMs) @@ -457,7 +461,7 @@ class ChatMetadataDaoTest { assertEquals(0L, stored.viewerStateVersion) // A rejoin's first state numbers from the bottom again, and must still apply. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, canEdit = false, version = 1) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, canEdit = false, canInvite = false, version = 1) assertEquals(9_000L, dao.getById(CHAT_HEX)?.muteUntilEpochMs) } @@ -468,10 +472,10 @@ class ChatMetadataDaoTest { @Test fun `an older viewer state leaves the edit grant alone`() = runTest { dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 4)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 5) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = false, version = 5) // The withdrawal this supersedes, arriving late. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 3) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, canInvite = false, version = 3) assertEquals(true, dao.getById(CHAT_HEX)?.canEdit) } @@ -484,7 +488,7 @@ class ChatMetadataDaoTest { fun `a newer viewer state withdraws the edit grant`() = runTest { dao.upsert(entity(chatType = "GROUP", canEdit = true, viewerStateVersion = 4)) - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, version = 5) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, canInvite = false, version = 5) assertEquals(false, dao.getById(CHAT_HEX)?.canEdit) } @@ -511,11 +515,44 @@ class ChatMetadataDaoTest { db.openHelper.writableDatabase.execSQL(FlipcashDatabase.Migration36To37.REARM_VIEWER_STATE) // The version the row already held, which the gate would otherwise drop. - dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, version = 7) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = false, version = 7) assertEquals(true, dao.getById(CHAT_HEX)?.canEdit) } + @Test + fun `a newer viewer state grants and withdraws the invite right`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 4)) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = true, version = 5) + assertEquals(true, dao.getById(CHAT_HEX)?.canInvite) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = true, canInvite = false, version = 6) + assertEquals(false, dao.getById(CHAT_HEX)?.canInvite) + } + + /** A chat the user has left grants nothing, invite right included. */ + @Test + fun `clearing the viewer state drops the invite grant`() = runTest { + dao.upsert(entity(chatType = "GROUP", canInvite = true, viewerStateVersion = 7)) + + dao.clearViewerState(CHAT_HEX) + + assertEquals(false, dao.getById(CHAT_HEX)?.canInvite) + } + + /** Same reason as the v37 re-arm, for the invite right added at v46. */ + @Test + fun `the v46 re-arm lets the next fetch fill the invite grant`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 7)) + + db.openHelper.writableDatabase.execSQL(FlipcashDatabase.Migration36To37.REARM_VIEWER_STATE) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = false, canEdit = false, canInvite = true, version = 7) + + assertEquals(true, dao.getById(CHAT_HEX)?.canInvite) + } + @Test fun `the viewer state version reads back, and is null for a chat that is not there`() = runTest { dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 2db45e47a3..b0b5941b13 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -109,6 +109,7 @@ class ChatMetadataDataSource @Inject constructor( muteUntilEpochMs = mapper.muteUntilEpochMs(viewerState.mute), muteForever = mapper.isMuteForever(viewerState.mute), canEdit = viewerState.permissions.canEdit, + canInvite = viewerState.permissions.canInvite, version = viewerState.version, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 6fe287176f..c4ca8636a1 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -98,6 +98,7 @@ class ChatEntityMapper @Inject constructor() { muteForever = isMuteForever(metadata.viewerState?.mute), viewerStateVersion = metadata.viewerState?.version ?: 0, canEdit = metadata.viewerState?.permissions?.canEdit ?: false, + canInvite = metadata.viewerState?.permissions?.canInvite ?: false, creatorHex = metadata.creator?.hexEncodedString(), useE2ee = metadata.useE2ee, isPrivate = metadata.isPrivate, @@ -153,7 +154,7 @@ class ChatEntityMapper @Inject constructor() { * the domain model disagree about what is stored, and a later write gate compare against a * version whose state had been silently rewritten. * - * A granted `can_edit` counts as state held about the viewer, so it keeps the row out of the + * A granted `can_edit` or `can_invite` counts as state held about the viewer, so it keeps the row out of the * null case on its own: a chat nobody has muted still has to report that the viewer may edit * it, and returning null there would deny an edit the server allows. */ @@ -165,11 +166,11 @@ class ChatEntityMapper @Inject constructor() { until != null -> MuteState.Until(Instant.fromEpochMilliseconds(until)) else -> null } - if (mute == null && viewerStateVersion == 0L && !canEdit) return null + if (mute == null && viewerStateVersion == 0L && !canEdit && !canInvite) return null return ViewerState( mute = mute, version = viewerStateVersion, - permissions = ViewerState.Permissions(canEdit = canEdit), + permissions = ViewerState.Permissions(canEdit = canEdit, canInvite = canInvite), ) } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSourceTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSourceTest.kt index 6c7df7c2e5..b5b12f2ff8 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSourceTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSourceTest.kt @@ -1,10 +1,18 @@ package com.flipcash.app.persistence.sources import androidx.paging.PagingSource +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.dao.ChatMetadataDao import com.flipcash.app.persistence.entities.ChatMetadataEntity import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ViewerState +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -39,4 +47,31 @@ class ChatMetadataDataSourceTest { fun `a chat id renders as the hex the tables are keyed by`() { assertEquals("ab", dataSource.chatIdHex(ChatId(listOf(0xAB.toByte())))) } + + @Test + fun `the invite grant is stored with the rest of the viewer state`() = runTest { + val dao = mockk(relaxed = true) + mockkObject(FlipcashDatabase.Companion) + try { + every { FlipcashDatabase.getInstance() } returns mockk { every { chatMetadataDao() } returns dao } + + dataSource.updateViewerState( + ChatId(listOf(0xAB.toByte())), + ViewerState(version = 3, permissions = ViewerState.Permissions(canInvite = true)), + ) + + coVerify { + dao.updateViewerStateIfNewer( + chatIdHex = "ab", + muteUntilEpochMs = any(), + muteForever = false, + canEdit = false, + canInvite = true, + version = 3, + ) + } + } finally { + unmockkObject(FlipcashDatabase.Companion) + } + } } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index a9db3a7393..ae23378044 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -133,6 +133,31 @@ class ChatEntityMapperTest { assertEquals(false, entity(MessageContent.Text("still here")).isDeleted) } + /** The 5b–5d copy names the inviter, so a stored join has to keep who invited them. */ + @Test + fun `an invite join keeps its inviter through the round trip`() { + val joined = MessageContent.System( + fallbackText = "joined", + event = MessageContent.SystemEvent.UserJoined( + userId = listOf(0x01.toByte()), + admittedBy = null, + invitedBy = listOf(0x02.toByte()), + ), + ) + val entity = mapper.toEntity( + CHAT_HEX, + ChatMessage( + messageId = 1, + senderId = listOf(0xAB.toByte()), + content = listOf(joined), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 1, + ), + ) + + assertEquals(joined, mapper.toMessage(entity).content.single()) + } + @Test fun `a denied row maps to a denied delivery`() { val entity = mapper.toEntity( @@ -545,6 +570,20 @@ class ChatEntityMapperTest { assertEquals(true, restored.viewerState?.permissions?.canEdit) } + @Test + fun `an invite grant survives the round trip`() { + val entity = mapper.toEntity( + groupMetadata().copy( + viewerState = ViewerState(permissions = ViewerState.Permissions(canInvite = true)) + ) + ) + assertEquals(true, entity.canInvite) + + val restored = mapper.toMetadata(entity, emptyList(), null) + + assertEquals(true, restored.viewerState?.permissions?.canInvite) + } + // region Reactions @Test diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt index 594726e814..2b701a1f2f 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt @@ -4,9 +4,12 @@ import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.GetKeyEnvelopeError import com.flipcash.services.models.SetKeyEnvelopeError import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.InviteKeyBundle import com.flipcash.services.models.chat.KeyEnvelope import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.EncryptedPayload +import com.getcode.chatcipher.InviteBundleResult +import com.getcode.chatcipher.InviteCipher import com.getcode.ed25519kmp.KeyPair import com.getcode.opencode.model.core.ID import kotlinx.coroutines.sync.Mutex @@ -25,8 +28,10 @@ import javax.inject.Singleton @Singleton class GroupKeyEnvelopes @Inject constructor( private val cipher: ChatCipher, + private val inviteCipher: InviteCipher, private val keys: ChatKeySource, private val chatController: ChatController, + private val wrongKeyChats: WrongKeyChats, ) : GroupKeySource { // Own public key in the key so a different account on this device never reuses a group key. private data class CacheKey(val chatId: ID, val ownPk: ID) @@ -74,6 +79,11 @@ class GroupKeyEnvelopes @Inject constructor( * * `NO_ENVELOPE` means a creator whose setup stopped before [storeNewKey] landed; only the * creator regenerates the key there. + * + * A member who joined with an invite link gets the invite's copy of the key until they store + * their own envelope. It is opened and re-wrapped the same way; a copy that fails a check marks + * the chat in [WrongKeyChats]. A fetch that fails for any other reason is not cached, so the + * next call, such as opening the chat, tries again. */ override suspend fun key(chatId: ChatId): Result { val own = keys.ownKeyPair() @@ -84,7 +94,10 @@ class GroupKeyEnvelopes @Inject constructor( return locks.getOrPut(cacheKey) { Mutex() }.withLock { groupKeys[cacheKey]?.let { return@withLock Result.success(it) } fetchKey(chatId, own) - .onSuccess { groupKeys[cacheKey] = it } + .onSuccess { + groupKeys[cacheKey] = it + wrongKeyChats.clear(chatId) + } } } @@ -97,6 +110,9 @@ class GroupKeyEnvelopes @Inject constructor( ?: fetchKey(chatId, own, retried = true).getOrThrow() } } + if (error is GetKeyEnvelopeError.InviteKeyBundleOnly) { + return openInviteBundle(chatId, own, error.bundle, retried) + } return Result.failure(error) } if (stored.envelope.scheme != KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305) { @@ -117,7 +133,53 @@ class GroupKeyEnvelopes @Inject constructor( val creatorPk = keys.peerPublicKey(creator).getOrElse { return Result.failure(it) } val groupKey = runCatching { cipher.unwrapGroupKey(own, creatorPk, chatId.bytes, payload) } .getOrElse { return Result.failure(UntrustedEnvelope("Creator-wrapped envelope did not open", it)) } + return storeSelfCopy(chatId, own, groupKey, retried) + } + + /** + * Opens the invite's copy of the key in the order the `InviteKeyEnvelope` comment sets. The + * creator's key comes from the Resolver like a creator-wrapped envelope's, and falls back to the + * `wrapped_by_public_key` the server reports when the Resolver can't answer. + */ + private suspend fun openInviteBundle( + chatId: ChatId, + own: KeyPair, + bundle: InviteKeyBundle, + retried: Boolean, + ): Result { + val creator = chatController.getChat(chatId).getOrElse { return Result.failure(it) }.creator + val creatorPk = creator?.let { keys.peerPublicKey(it) }?.getOrNull() + ?: bundle.wrappedByPublicKey?.byteArray + ?: return Result.failure(IllegalStateException("No public key for the invite's creator")) + val keyEnvelope = bundle.keyEnvelope.toSealed() + val secretEnvelope = bundle.secretEnvelope.toPayload() + // A scheme this build doesn't implement says nothing about the key, so it is refused the + // way an unsupported KeyEnvelope is, without the wrong-key mark. + if (keyEnvelope == null || secretEnvelope == null) { + return Result.failure(UntrustedEnvelope("Unsupported invite envelope scheme")) + } + + val opened = inviteCipher.openInviteKeyBundle( + member = own, + creatorPk = creatorPk, + wrappedByCreator = creator != null && bundle.wrappedBy == creator, + chatId = chatId.bytes, + inviteId = bundle.inviteId.bytes, + keyEnvelope = keyEnvelope, + secretEnvelope = secretEnvelope, + ) + return when (opened) { + is InviteBundleResult.Opened -> storeSelfCopy(chatId, own, opened.chatKey, retried) + is InviteBundleResult.Rejected -> wrongKey(chatId, "Invite key bundle rejected at ${opened.failsAt}") + } + } + + private fun wrongKey(chatId: ChatId, reason: String): Result { + wrongKeyChats.mark(chatId) + return Result.failure(UntrustedEnvelope(reason)) + } + private suspend fun storeSelfCopy(chatId: ChatId, own: KeyPair, groupKey: ByteArray, retried: Boolean): Result { // The self-wrapped copy only saves the next launch a trip through the creator's envelope, so // failing to store it doesn't cost the key that just opened. val selfWrapped = runCatching { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/InviteEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/InviteEnvelopes.kt new file mode 100644 index 0000000000..5331b5785d --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/InviteEnvelopes.kt @@ -0,0 +1,24 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.InviteKeyEnvelope +import com.flipcash.services.models.chat.InviteSecretEnvelope +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.chatcipher.InviteKeyEnvelope as SealedInviteKey + +/* + * Moves invite envelopes between the transport models and the chat-cipher types. The cipher + * implements one scheme per message and leaves checking it to the caller, so an envelope in any + * other scheme has no cipher form and comes back null. + */ + +fun InviteKeyEnvelope.toSealed(): SealedInviteKey? = + if (scheme == InviteKeyEnvelope.SCHEME_XCHACHA20POLY1305) SealedInviteKey(nonce, ciphertext, signature) else null + +fun SealedInviteKey.toEnvelope(): InviteKeyEnvelope = + InviteKeyEnvelope(InviteKeyEnvelope.SCHEME_XCHACHA20POLY1305, nonce, ciphertext, signature) + +fun InviteSecretEnvelope.toPayload(): EncryptedPayload? = + if (scheme == InviteSecretEnvelope.SCHEME_X25519_XCHACHA20POLY1305) EncryptedPayload(nonce, ciphertext) else null + +fun EncryptedPayload.toInviteSecretEnvelope(): InviteSecretEnvelope = + InviteSecretEnvelope(InviteSecretEnvelope.SCHEME_X25519_XCHACHA20POLY1305, nonce, ciphertext) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt new file mode 100644 index 0000000000..76cb8e4797 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/WrongKeyChats.kt @@ -0,0 +1,41 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Private groups whose invite key bundle failed a check: the member joined, but the copy of the + * chat key their link carried is not one they can trust. The chat shows the wrong-key state and + * must not send. The contract's only repair is to leave and be admitted from the lobby + * (`KeyEnvelope` comment), so nothing here retries. + * + * [GroupKeyEnvelopes] writes it; a chat whose key later opens drops out. Held in memory only: the + * next launch fetches the bundle again and lands here again if it still fails. Logout calls + * [reset], since chat ids aren't scoped to the account that marked them. + */ +@Singleton +class WrongKeyChats @Inject constructor() { + private val wrong = MutableStateFlow>(emptySet()) + + val chats: StateFlow> = wrong.asStateFlow() + + fun observe(chatId: ChatId): Flow = wrong.map { chatId in it }.distinctUntilChanged() + + internal fun mark(chatId: ChatId) = wrong.update { it + chatId } + + internal fun clear(chatId: ChatId) { + if (chatId in wrong.value) wrong.update { it - chatId } + } + + fun reset() { + wrong.value = emptySet() + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt index 76c4cc2d78..5d04fb7df3 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt @@ -6,6 +6,8 @@ import com.flipcash.services.chat.GroupKeyEnvelopes import com.flipcash.services.chat.GroupKeySource import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.DefaultChatCipher +import com.getcode.chatcipher.DefaultInviteCipher +import com.getcode.chatcipher.InviteCipher import dagger.Binds import dagger.Module import dagger.Provides @@ -25,5 +27,8 @@ internal abstract class ChatCryptoModule { companion object { @Provides fun provideChatCipher(): ChatCipher = DefaultChatCipher + + @Provides + fun provideInviteCipher(): InviteCipher = DefaultInviteCipher } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt index 8ce70c32b2..1cb3465d79 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt @@ -5,10 +5,14 @@ import com.flipcash.services.models.GetKeyEnvelopeError import com.flipcash.services.models.SetKeyEnvelopeError import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.InviteId +import com.flipcash.services.models.chat.InviteKeyBundle +import com.flipcash.services.models.chat.InviteSecretEnvelope import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.StoredKeyEnvelope import com.getcode.chatcipher.EncryptedPayload import com.getcode.ed25519kmp.KeyPair +import com.getcode.solana.keys.PublicKey import com.getcode.opencode.model.core.ID import io.mockk.coEvery import io.mockk.coVerify @@ -20,6 +24,7 @@ import org.junit.Test import java.io.IOException import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class GroupKeyEnvelopesTest { @@ -33,7 +38,7 @@ class GroupKeyEnvelopesTest { override suspend fun peerPublicKey(userId: ID): Result = error("unused") } private val controller = mockk() - private val envelopes = GroupKeyEnvelopes(FakeChatCipher, keys, controller) + private val envelopes = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), keys, controller, WrongKeyChats()) @Test fun `storeNewKey stores a self-wrapped envelope`() = runTest { @@ -96,13 +101,15 @@ class GroupKeyEnvelopesTest { private val creatorId: ID = List(16) { 1 } private val memberId: ID = List(16) { 2 } private val memberKeys = KeyPair(publicKey = memberPk, privateKey = ByteArray(64) { 22 }) + private val inviteCipher = FakeInviteCipher() + private val wrongKeyChats = WrongKeyChats() private val memberSource = object : ChatKeySource { override fun ownKeyPair() = memberKeys override fun ownUserId() = memberId override suspend fun peerPublicKey(userId: ID): Result = if (userId == creatorId) Result.success(creatorKeys.publicKey) else Result.failure(IOException("unknown")) } - private val memberEnvelopes = GroupKeyEnvelopes(FakeChatCipher, memberSource, controller) + private val memberEnvelopes = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, memberSource, controller, wrongKeyChats) private val groupKey = ByteArray(32) { 5 } private fun stored(wrapper: KeyPair, recipient: ByteArray, wrappedBy: ID?): StoredKeyEnvelope { @@ -193,11 +200,164 @@ class GroupKeyEnvelopesTest { override fun ownUserId() = creatorId override suspend fun peerPublicKey(userId: ID): Result = error("unused") } - val creatorEnvelopes = GroupKeyEnvelopes(FakeChatCipher, creatorSource, controller) + val creatorEnvelopes = GroupKeyEnvelopes(FakeChatCipher, FakeInviteCipher(), creatorSource, controller, WrongKeyChats()) coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.NoEnvelope()) coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) assertContentEquals(FakeChatCipher.newGroupKey(), creatorEnvelopes.key(chatId).getOrThrow()) } + + // Invite path: the member joined with a link and the server holds only the invite's copy. + + private val secret = ByteArray(32) { 3 } + private val inviteId = InviteId(inviteCipher.inviteId(secret)) + + private fun bundle( + signer: KeyPair = creatorKeys, + wrappedBy: ID? = creatorId, + secretOwner: KeyPair = memberKeys, + sealedFor: ByteArray = secret, + secretScheme: Int = InviteSecretEnvelope.SCHEME_X25519_XCHACHA20POLY1305, + wrappedByPublicKey: PublicKey? = null, + ): GetKeyEnvelopeError.InviteKeyBundleOnly { + val copy = inviteCipher.sealInviteKeyEnvelope(signer, sealedFor, chatId.bytes, groupKey).toEnvelope() + val own = inviteCipher.sealInviteSecretEnvelope(secretOwner, secret, chatId.bytes, inviteId.bytes) + return GetKeyEnvelopeError.InviteKeyBundleOnly( + InviteKeyBundle( + inviteId = inviteId, + keyEnvelope = copy, + secretEnvelope = InviteSecretEnvelope(secretScheme, own.nonce, own.ciphertext), + wrappedBy = wrappedBy, + wrappedByPublicKey = wrappedByPublicKey, + ), + ) + } + + private fun assertWrongKey(error: GetKeyEnvelopeError.InviteKeyBundleOnly) = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(error) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + assertTrue(chatId in wrongKeyChats.chats.value) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `an invite bundle opens and the member stores a self-wrapped copy`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + val rewrapped = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(rewrapped)) } returns Result.success(Unit) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + + assertContentEquals(memberPk + memberPk + groupKey, rewrapped.captured.ciphertext) + assertFalse(chatId in wrongKeyChats.chats.value) + } + + @Test + fun `a failed store after the bundle opens still returns the key`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(IOException("offline")) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + } + + @Test + fun `a bundle wrapped by someone other than the creator is the wrong key`() = + assertWrongKey(bundle(wrappedBy = List(16) { 9 })) + + @Test + fun `a bundle not signed by the creator is the wrong key`() = + assertWrongKey(bundle(signer = KeyPair(publicKey = ByteArray(32) { 99.toByte() }, privateKey = ByteArray(64)))) + + @Test + fun `a bundle whose secret envelope does not open is the wrong key`() = + assertWrongKey(bundle(secretOwner = KeyPair(publicKey = ByteArray(32) { 77 }, privateKey = ByteArray(64)))) + + @Test + fun `a bundle whose copy does not open is the wrong key`() = + assertWrongKey(bundle(sealedFor = ByteArray(32) { 4 })) + + @Test + fun `a bundle in an unknown scheme is refused without the wrong-key mark`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle(secretScheme = 9)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + assertFalse(chatId in wrongKeyChats.chats.value) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `the reported creator key opens the bundle when the creator's key can't be looked up`() = runTest { + val offline = object : ChatKeySource { + override fun ownKeyPair() = memberKeys + override fun ownUserId() = memberId + override suspend fun peerPublicKey(userId: ID): Result = Result.failure(IOException("offline")) + } + val envelopes = GroupKeyEnvelopes(FakeChatCipher, inviteCipher, offline, controller, wrongKeyChats) + coEvery { controller.getKeyEnvelope(chatId) } returns + Result.failure(bundle(wrappedByPublicKey = PublicKey(creatorKeys.publicKey.toList()))) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) + + assertContentEquals(groupKey, envelopes.key(chatId).getOrThrow()) + } + + @Test + fun `ALREADY_SET after the bundle opens reads the envelope another device stored`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returnsMany listOf( + Result.failure(bundle()), + Result.success(stored(memberKeys, memberPk, memberId)), + ) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(SetKeyEnvelopeError.AlreadySet()) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + coVerify(exactly = 2) { controller.getKeyEnvelope(chatId) } + } + + @Test + fun `a rejected bundle is not cached and is fetched again on the next open`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(bundle(wrappedBy = List(16) { 9 })) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertTrue(memberEnvelopes.key(chatId).isFailure) + coVerify(exactly = 2) { controller.getKeyEnvelope(chatId) } + } + + @Test + fun `a network failure is not cached and the next open retries`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returnsMany listOf( + Result.failure(GetKeyEnvelopeError.Other(IOException("offline"))), + Result.failure(bundle()), + ) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertFalse(chatId in wrongKeyChats.chats.value) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + coVerify(exactly = 2) { controller.getKeyEnvelope(chatId) } + } + + @Test + fun `a key that opens later clears the wrong-key mark`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returnsMany listOf( + Result.failure(bundle(wrappedBy = List(16) { 9 })), + Result.success(stored(memberKeys, memberPk, memberId)), + ) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + assertTrue(chatId in wrongKeyChats.chats.value) + + memberEnvelopes.key(chatId).getOrThrow() + assertFalse(chatId in wrongKeyChats.chats.value) + } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/WrongKeyChatsTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/WrongKeyChatsTest.kt new file mode 100644 index 0000000000..6d0002e981 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/WrongKeyChatsTest.kt @@ -0,0 +1,36 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WrongKeyChatsTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val other = ChatId(ByteArray(32) { 8 }) + private val chats = WrongKeyChats() + + @Test + fun `a marked chat is observed until it is cleared`() = runTest { + assertFalse(chats.observe(chatId).first()) + + chats.mark(chatId) + assertTrue(chats.observe(chatId).first()) + assertFalse(chats.observe(other).first()) + + chats.clear(chatId) + assertFalse(chats.observe(chatId).first()) + } + + @Test + fun `marking twice keeps one entry`() { + chats.mark(chatId) + chats.mark(ChatId(ByteArray(32) { 7 })) + + assertEquals(setOf(chatId), chats.chats.value) + } +} diff --git a/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeInviteCipher.kt b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeInviteCipher.kt new file mode 100644 index 0000000000..a84c604188 --- /dev/null +++ b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeInviteCipher.kt @@ -0,0 +1,73 @@ +package com.flipcash.services.chat + +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.chatcipher.InviteBundleFailure +import com.getcode.chatcipher.InviteBundleResult +import com.getcode.chatcipher.InviteCipher +import com.getcode.chatcipher.InviteKeyEnvelope +import com.getcode.ed25519kmp.KeyPair + +/** + * Stands in for [com.getcode.chatcipher.DefaultInviteCipher], whose libsodium binding can't load + * on a JVM host. Envelopes carry what sealed them in the clear, so opening fails unless the + * secret, chat, invite and keys all match, and [openInviteKeyBundle] rejects at the same step + * the real one would. + */ +class FakeInviteCipher : InviteCipher { + private var minted = 0 + + /** Each call returns a different secret: the n-th is all bytes n. */ + override fun newSecret(): ByteArray = ByteArray(32) { (minted + 1).toByte() }.also { minted++ } + + override fun inviteToken(secret: ByteArray): ByteArray = ByteArray(32) { (secret[it].toInt() xor 0x5a).toByte() } + + override fun inviteId(secret: ByteArray): ByteArray = inviteToken(secret).copyOf(16) + + override fun sealInviteKeyEnvelope(inviter: KeyPair, secret: ByteArray, chatId: ByteArray, chatKey: ByteArray) = + InviteKeyEnvelope( + nonce = ByteArray(24), + ciphertext = secret + chatId + chatKey, + signature = inviter.publicKey + ByteArray(32), + ) + + override fun openInviteKeyEnvelope(secret: ByteArray, chatId: ByteArray, inviterPk: ByteArray, envelope: InviteKeyEnvelope): ByteArray { + if (!envelope.signature.copyOf(32).contentEquals(inviterPk)) throw ChatCipherException("signature does not verify") + return openWithHeader(envelope.ciphertext, secret + chatId) + } + + override fun sealInviteSecretEnvelope(owner: KeyPair, secret: ByteArray, chatId: ByteArray, inviteId: ByteArray) = + EncryptedPayload(nonce = ByteArray(24), ciphertext = owner.publicKey + chatId + inviteId + secret) + + override fun openInviteSecretEnvelope(owner: KeyPair, chatId: ByteArray, inviteId: ByteArray, envelope: EncryptedPayload): ByteArray = + openWithHeader(envelope.ciphertext, owner.publicKey + chatId + inviteId) + + override fun openInviteKeyBundle( + member: KeyPair, + creatorPk: ByteArray, + wrappedByCreator: Boolean, + chatId: ByteArray, + inviteId: ByteArray, + keyEnvelope: InviteKeyEnvelope, + secretEnvelope: EncryptedPayload, + ): InviteBundleResult { + if (!wrappedByCreator) return InviteBundleResult.Rejected(InviteBundleFailure.WrappedBy) + if (!keyEnvelope.signature.copyOf(32).contentEquals(creatorPk)) { + return InviteBundleResult.Rejected(InviteBundleFailure.Signature) + } + val secret = runCatching { openInviteSecretEnvelope(member, chatId, inviteId, secretEnvelope) }.getOrNull() + if (secret == null || !inviteId(secret).contentEquals(inviteId)) { + return InviteBundleResult.Rejected(InviteBundleFailure.SecretOpen) + } + val chatKey = runCatching { openWithHeader(keyEnvelope.ciphertext, secret + chatId) }.getOrNull() + ?: return InviteBundleResult.Rejected(InviteBundleFailure.KeyOpen) + return InviteBundleResult.Opened(chatKey) + } + + private fun openWithHeader(sealed: ByteArray, header: ByteArray): ByteArray { + if (sealed.size < header.size || !sealed.copyOfRange(0, header.size).contentEquals(header)) { + throw ChatCipherException("authentication failed") + } + return sealed.copyOfRange(header.size, sealed.size) + } +}