diff --git a/Makefile b/Makefile index cefa712cda7524012c97004b48ddb1e62816fef6..2a216e1b47600ba21525b784257378a8271ba502 100644 --- a/Makefile +++ b/Makefile @@ -382,6 +382,14 @@ sc.db.rollback_n: ## run migrations docker-compose -f docker-compose-sc.yml \ run -e SIGNALC_ENV=test --entrypoint 'gradle --console=plain rollbackCount -PliquibaseCommandValue=$(N)' signalc +sc.db.clear_checksums: ## run migrations + echo "----- rolling back 1 development migration" && \ + docker-compose -f docker-compose-sc.yml \ + run -e SIGNALC_ENV=development --entrypoint 'gradle --console=plain clearCheckSums' signalc && \ + echo "----- rolling back 1 test migration" && \ + docker-compose -f docker-compose-sc.yml \ + run -e SIGNALC_ENV=test --entrypoint 'gradle --console=plain clearCheckSums' signalc + sc.db.psql: # get a psql shell on signalc db ./bin/sc/psql diff --git a/docs/2-signals-on-1-device.md b/docs/2-signals-on-1-device.md index ff589d5ffb48dbdf8c6c1c7e35d65bcfdd598cab..0b1b0ade90efbd7e055163a0a7227ea2ddcbaf3d 100644 --- a/docs/2-signals-on-1-device.md +++ b/docs/2-signals-on-1-device.md @@ -44,12 +44,14 @@ Create a new desktop entry file: sudo touch /usr/share/applications/signal-desktop-alice ``` -Edit that file to have the following contents +Edit that file to have the following contents: + +(note -- it is necessary to provide the absolute path to your home directory) ``` [Desktop Entry] Name=Signal-Alice -Exec=/opt/Signal/signal-desktop --no-sandbox --user-data-dir=$HOME/.config/Signal-Alice %U +Exec=/opt/Signal-Alice/signal-desktop --no-sandbox --user-data-dir=<absolute-path-to-$HOME>/.config/Signal-Alice %U Terminal=false Type=Application Icon=signal-desktop diff --git a/signalc/migrations/changelog.postgresql.sql b/signalc/migrations/changelog.postgresql.sql index 9e5aca8d4743aaaa417256566b06b63f98dcd09d..2da95ea918b858037d581a945a33930e3257a834 100644 --- a/signalc/migrations/changelog.postgresql.sql +++ b/signalc/migrations/changelog.postgresql.sql @@ -155,3 +155,111 @@ ALTER TABLE identities -- rollback ALTER TABLE identities -- rollback DROP COLUMN created_at, -- rollback DROP COLUMN updated_at; + +-- changeset aguestuser:1622741296513-1 failOnError:true +CREATE TABLE IF NOT EXISTS contacts ( + account_id VARCHAR(255), + contact_id SERIAL, + uuid uuid NULL, + phone_number VARCHAR(255) NOT NULL, + profile_key_bytes bytea NULL, + CONSTRAINT pk_Contacts PRIMARY KEY (contact_id, account_id) +); +CREATE INDEX contacts_account_id_uuid ON contacts (account_id, uuid); +CREATE INDEX contacts_account_id_phone_number ON contacts (account_id, phone_number); +-- rollback DROP TABLE contacts; + + +-- changeset aguestuser:1623100049239-1 failOnError:true +DELETE FROM accounts; +DELETE FROM contacts; +DELETE FROM identities; +DELETE FROM ownidentities; +DELETE FROM prekeys; +DELETE FROM profiles; +DELETE FROM senderkeys; +DELETE FROM sessions; +DELETE FROM signedprekeys; + +ALTER TABLE identities DROP CONSTRAINT pk_identities; +ALTER TABLE identities DROP COLUMN contact_id; +ALTER TABLE identities ADD COLUMN contact_id INT; +ALTER TABLE identities ADD CONSTRAINT pk_identities PRIMARY KEY (account_id, contact_id); + +ALTER TABLE sessions DROP CONSTRAINT pk_sessions; +ALTER TABLE sessions DROP COLUMN contact_id; +ALTER TABLE sessions ADD COLUMN contact_id INT; +ALTER TABLE sessions ADD CONSTRAINT pk_sessions PRIMARY KEY (account_id, contact_id, device_id); +-- rollback DELETE FROM accounts; +-- rollback DELETE FROM contacts; +-- rollback DELETE FROM identities; +-- rollback DELETE FROM ownidentities; +-- rollback DELETE FROM prekeys; +-- rollback DELETE FROM profiles; +-- rollback DELETE FROM senderkeys; +-- rollback DELETE FROM sessions; +-- rollback DELETE FROM signedprekeys; +-- rollback +-- rollback ALTER TABLE identities DROP CONSTRAINT pk_identities; +-- rollback ALTER TABLE identities DROP COLUMN contact_id; +-- rollback ALTER TABLE identities ADD COLUMN contact_id VARCHAR(255) NOT NULL; +-- rollback ALTER TABLE identities ADD CONSTRAINT pk_identities PRIMARY KEY (account_id, contact_id); +-- rollback +-- rollback ALTER TABLE sessions DROP CONSTRAINT pk_sessions; +-- rollback ALTER TABLE sessions DROP COLUMN contact_id; +-- rollback ALTER TABLE sessions ADD COLUMN contact_id VARCHAR(255) NOT NULL; +-- rollback ALTER TABLE sessions ADD CONSTRAINT pk_sessions PRIMARY KEY (account_id, contact_id, device_id); + +-- changeset aguestuser:1623100049239-2 failOnError:true +DROP INDEX identities_identity_key_bytes; +-- rollback CREATE INDEX identities_identity_key_bytes ON identities (identity_key_bytes); + + +-- changeset fdbk:1623280052786-1 failOnError:true +ALTER TABLE contacts ALTER COLUMN phone_number DROP NOT NULL; +-- rollback ALTER TABLE contacts ALTER COLUMN phone_number SET NOT NULL; + +-- changeset aguestuser:1624301616527-1 failOnError:true +-- NOTE: This migration introduces uniqueness constraints that will fail if there are any duplicate account_id/uuid or +-- account_id/phone_number combinations for any contact rows. Such dupes constitute an illegal state. +-- They are associated with bugs that we have now eradicated from the code. However, we nevertheless +-- begin this migration with 2 queries to clear all contacts having such an illegal state so that dev environments +-- created before the bug was eradicated may successfully run this migration. +-- Here we remove all contacts with duplicate account_id/uuid combos: +with uuid_counts as ( + select count(*), account_id, uuid from contacts + group by account_id, uuid + order by count(*) +), +uuid_dupes as ( + select account_id, uuid from uuid_counts where uuid_counts.count > 1 +) +delete from contacts where (account_id, uuid) in (select * from uuid_dupes); +-- Here we remove all contacts with duplicate account_id/phone_number combos: +with phone_number_counts as ( + select count(*), account_id, phone_number from contacts + group by account_id, phone_number + order by count(*) +), +phone_number_dupes as ( + select account_id, phone_number from phone_number_counts where phone_number_counts.count > 1 +) +delete from contacts where (account_id, phone_number) in (select * from phone_number_dupes); +-- And now we proceed to the migration! +DROP INDEX contacts_account_id_uuid; +CREATE UNIQUE INDEX contacts_account_id_uuid ON contacts (account_id, uuid); +DROP INDEX contacts_account_id_phone_number; +CREATE UNIQUE INDEX contacts_account_id_phone_number ON contacts (account_id, phone_number); +-- rollback DROP INDEX contacts_account_id_uuid; +-- rollback CREATE INDEX contacts_account_id_uuid ON contacts (account_id, uuid); +-- rollback DROP INDEX contacts_account_id_phone_number; +-- rollback CREATE INDEX contacts_account_id_phone_number ON contacts (account_id, phone_number); + +-- changeset aguestuser:1624301616527-2 failOnError:true +ALTER TABLE contacts ALTER COLUMN phone_number DROP DEFAULT; +-- rollback ALTER TABLE contacts ALTER COLUMN phone_number SET DEFAULT ''; + +-- changeset aguestuser:1624301616527-3 failOnError:true +ALTER TABLE contacts ADD CONSTRAINT phone_number_regex CHECK(phone_number ~ '^\+\d{9,15}\Z'); +-- rollback ALTER TABLE contacts DROP CONSTRAINT phone_number_regex; + diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/Application.kt b/signalc/src/main/kotlin/info/signalboost/signalc/Application.kt index 042d4c36b5ba2a7f32d274e7ed8d66ed9a1d3a04..9707f4f6546b09b299836ae42b743dc7dc8b34e3 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/Application.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/Application.kt @@ -7,7 +7,7 @@ import info.signalboost.signalc.logging.LibSignalLogger import info.signalboost.signalc.logic.* import info.signalboost.signalc.metrics.Metrics import info.signalboost.signalc.store.AccountStore -import info.signalboost.signalc.store.ProfileStore +import info.signalboost.signalc.store.ContactStore import info.signalboost.signalc.store.ProtocolStore import io.mockk.coEvery import io.mockk.mockk @@ -152,7 +152,7 @@ class Application(val config: Config.App){ // STORE // lateinit var accountStore: AccountStore - lateinit var profileStore: ProfileStore + lateinit var contactStore: ContactStore lateinit var protocolStore: ProtocolStore private lateinit var dataSource: HikariDataSource @@ -239,8 +239,8 @@ class Application(val config: Config.App){ // storage resources dataSource = initializeDataSource(Mocks.dataSource) - accountStore = initializeColdComponent(AccountStore::class) - profileStore = initializeColdComponent(ProfileStore::class, Mocks.profileStore) + accountStore = initializeColdComponent(AccountStore::class, Mocks.accountStore) + contactStore = initializeColdComponent(ContactStore::class, Mocks.contactStore) protocolStore = initializeColdComponent(ProtocolStore::class, Mocks.protocolStore) // network resources diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/Config.kt b/signalc/src/main/kotlin/info/signalboost/signalc/Config.kt index 5424c5442125759d71f8a328c8e2c67c93420f17..57dbb78765bba41877d8d0c597e5f0bbf7e80164 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/Config.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/Config.kt @@ -4,7 +4,7 @@ import com.zaxxer.hikari.HikariDataSource import info.signalboost.signalc.logic.* import info.signalboost.signalc.metrics.Metrics import info.signalboost.signalc.store.AccountStore -import info.signalboost.signalc.store.ProfileStore +import info.signalboost.signalc.store.ContactStore import info.signalboost.signalc.store.ProtocolStore import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi @@ -26,7 +26,7 @@ object Config { HikariDataSource::class, AccountStore::class, ProtocolStore::class, - ProfileStore::class, + ContactStore::class, // components AccountManager::class, SignalReceiver::class, diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt b/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt index 177c7126a96bd5be1517d6ee6b01987b7b330751..dc5137de2d4a0e87ad0bb337a396381078dc7e0e 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt @@ -3,9 +3,9 @@ package info.signalboost.signalc import com.zaxxer.hikari.HikariDataSource import info.signalboost.signalc.logic.* import info.signalboost.signalc.metrics.Metrics -import info.signalboost.signalc.model.SignalcAddress -import info.signalboost.signalc.model.SignalcSendResult -import info.signalboost.signalc.store.ProfileStore +import info.signalboost.signalc.model.* +import info.signalboost.signalc.store.AccountStore +import info.signalboost.signalc.store.ContactStore import info.signalboost.signalc.store.ProtocolStore import io.mockk.coEvery import io.mockk.every @@ -29,14 +29,24 @@ object Mocks { coEvery { publishPreKeys(any()) } returns Unit coEvery { publishPreKeys(any(), any()) } returns Unit coEvery { refreshPreKeysIfDepleted(any()) } returns Unit - coEvery { getUnidentifiedAccessPair(any(), any()) } returns mockk() + coEvery { getUnidentifiedAccessPair(any(), any()) } returns null + } + val accountStore: AccountStore.() -> Unit = { + coEvery { save(any<NewAccount>()) } returns Unit + coEvery { save(any<RegisteredAccount>()) } returns Unit + coEvery { save(any<VerifiedAccount>()) } returns Unit } val dataSource: HikariDataSource.() -> Unit = { every { closeQuietly() } returns Unit } - val profileStore: ProfileStore.() -> Unit = { - coEvery { storeProfileKey(any(), any(), any())} returns Unit + val contactStore: ContactStore.() -> Unit = { + coEvery { create(any(), any()) } returns 0 + coEvery { create(any(), any(), any(), any()) } returns 0 + coEvery { createOwnContact(any()) } returns 0 + coEvery { hasContact(any(), any()) } returns true coEvery { loadProfileKey(any(), any())} returns mockk() + coEvery { storeMissingIdentifier(any(), any(), any())} returns Unit + coEvery { storeProfileKey(any(), any(), any())} returns Unit } val protocolStore: ProtocolStore.() -> Unit = { every { of(any()) } returns mockk { @@ -65,6 +75,12 @@ object Mocks { coEvery { send(any(), any(), any(), any(), any(), any()) } answers { mockkSuccessOf(secondArg()) } + coEvery { sendProfileKey(any(), any(), any()) } answers { + mockkSuccessOf(secondArg()) + } + coEvery { sendReceipt(any(), any(), any()) } answers { + mockkSuccessOf(secondArg()) + } coEvery { setExpiration(any(), any(), any()) } answers { mockkSuccessOf(secondArg()) } diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/ContactRecord.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/ContactRecord.kt index bfa50aca3978fc8bacc8374f6db0391a9851f949..0d3e5fa173ddc482aa4948b9879b1935dbfe801a 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/db/ContactRecord.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/ContactRecord.kt @@ -10,18 +10,18 @@ import org.jetbrains.exposed.sql.statements.UpdateStatement interface ContactRecord: FieldSet { val accountId: Column<String> - val contactId: Column<String> + val contactId: Column<Int> companion object { - fun ContactRecord.findByContactId(accountId: String, contactId: String): ResultRow? { + fun ContactRecord.findByContactId(accountId: String, contactId: Int): ResultRow? { val table = this return table.select { (table.accountId eq accountId).and(table.contactId eq contactId) }.singleOrNull() } - fun ContactRecord.findManyByContactId(accountId: String, contactId: String): List<ResultRow> { + fun ContactRecord.findManyByContactId(accountId: String, contactId: Int): List<ResultRow> { val table = this return table.select { (table.accountId eq accountId).and(table.contactId eq contactId) @@ -30,7 +30,7 @@ interface ContactRecord: FieldSet { fun ContactRecord.updateByContactId( accountId: String, - contactId: String, + contactId: Int, updateStatement: Table.(UpdateStatement) -> Unit ): Int { val table = this @@ -39,7 +39,7 @@ interface ContactRecord: FieldSet { }, null, updateStatement) } - fun ContactRecord.deleteByContactId(accountId: String, contactId: String): Int { + fun ContactRecord.deleteByContactId(accountId: String, contactId: Int): Int { val table = this return (table as Table).deleteWhere { (table.accountId eq accountId).and(table.contactId eq contactId) diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/Contacts.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/Contacts.kt new file mode 100644 index 0000000000000000000000000000000000000000..f2cdce9e19c725d91961b0633c4aa1f01c5b6235 --- /dev/null +++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/Contacts.kt @@ -0,0 +1,18 @@ +package info.signalboost.signalc.db + +import org.jetbrains.exposed.sql.Table + +object Contacts: Table(), ContactRecord { + override val accountId = varchar("account_id", 255) + override val contactId = integer("contact_id").autoIncrement() + val uuid = uuid("uuid").nullable() + val phoneNumber = varchar("phone_number", 255).nullable() + val profileKeyBytes = binary("profile_key_bytes").nullable() + + override val primaryKey = PrimaryKey(accountId, contactId) + + init { + index(isUnique = true, accountId, phoneNumber) + index(isUnique = true, accountId, uuid) + } +} diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/DeviceRecord.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/DeviceRecord.kt index 24aa4f9bc40005bdd806c3b160b94da4898104f0..669cfe0affcc681a48ac8b86515ae5e9e35963b7 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/db/DeviceRecord.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/DeviceRecord.kt @@ -2,43 +2,43 @@ package info.signalboost.signalc.db import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.statements.UpdateStatement -import org.whispersystems.libsignal.SignalProtocolAddress interface DeviceRecord: FieldSet { val accountId: Column<String> - val contactId: Column<String> + val contactId: Column<Int> val deviceId: Column<Int> companion object { - fun DeviceRecord.findByAddress(accountId: String, address: SignalProtocolAddress): ResultRow? { + fun DeviceRecord.findByDeviceId(accountId: String, contactId: Int, deviceId: Int): ResultRow? { val table = this return table.select { (table.accountId eq accountId) - .and(table.contactId eq address.name) - .and(table.deviceId eq address.deviceId) + .and(table.contactId eq contactId) + .and(table.deviceId eq deviceId) }.singleOrNull() } - fun DeviceRecord.updateByAddress( + fun DeviceRecord.updateByDeviceId( accountId: String, - address: SignalProtocolAddress, + contactId: Int, + deviceId: Int, updateStatement: Table.(UpdateStatement) -> Unit ): Int { val table = this return (table as Table).update ({ (table.accountId eq accountId) - .and(table.contactId eq address.name) - .and(table.deviceId eq address.deviceId) + .and(table.contactId eq contactId) + .and(table.deviceId eq deviceId) }, null, updateStatement) } - fun DeviceRecord.deleteByAddress(accountId: String, address: SignalProtocolAddress): Int { + fun DeviceRecord.deleteByDeviceId(accountId: String, contactId: Int, deviceId: Int): Int { val table = this return (table as Table).deleteWhere { (table.accountId eq accountId) - .and(table.contactId eq address.name) - .and(table.deviceId eq address.deviceId) + .and(table.contactId eq contactId) + .and(table.deviceId eq deviceId) } } } diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/Identities.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/Identities.kt index 4295ac292be2437fb68ef9e658e1273df6f50223..ac3b608d7a21018606ec8b4f2499698806a3a947 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/db/Identities.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/Identities.kt @@ -9,7 +9,7 @@ object Identities: Table(), ContactRecord { private const val IDENTITY_KEY_BYTE_ARRAY_LENGTH = 33 override val accountId = varchar("account_id", 255) - override val contactId = varchar("contact_id", 255) + override val contactId = integer("contact_id") val identityKeyBytes = binary("identity_key_bytes", IDENTITY_KEY_BYTE_ARRAY_LENGTH).index() val isTrusted = bool("is_trusted").default(true) val createdAt = timestamp("created_at").clientDefault { Instant.now() } diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/db/Sessions.kt b/signalc/src/main/kotlin/info/signalboost/signalc/db/Sessions.kt index 4ca084f585dd39c2806690a2ed5e459a869c8b59..b0d859c9fd8968362365f145922ddf4a3ac98b23 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/db/Sessions.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/db/Sessions.kt @@ -3,10 +3,9 @@ package info.signalboost.signalc.db import org.jetbrains.exposed.sql.Table - object Sessions: Table(), ContactRecord, DeviceRecord { override val accountId = varchar("account_id", 255) - override val contactId = varchar("contact_id", 255) + override val contactId = integer("contact_id") override val deviceId = integer("device_id") val sessionBytes = binary("session_bytes") diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/exception/SignalcError.kt b/signalc/src/main/kotlin/info/signalboost/signalc/exception/SignalcError.kt index 5e911d9847867c13304809af7372270b0a1f76de..1ade8209d588c15ca80c5646b276833e4a589bbc 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/exception/SignalcError.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/exception/SignalcError.kt @@ -8,9 +8,10 @@ object SignalcError { object RegistrationOfRegsisteredUser: Exception("Cannot register account that is already registered") object SubscriptionOfUnregisteredUser: Exception("Cannot subscribe to messages for unregistered account") class UpdateToNonExistentFingerprint( - contactId: String, + accountId: String, + contactId: Int, fingerprint: ByteArray, - ): Exception("Cannot update non-existent fingerprint ${fingerprint.toHex()} for contact $contactId") + ): Exception("Cannot update non-existent fingerprint ${fingerprint.toHex()} for contact $contactId of account $accountId") object UnsubscribeUnregisteredUser: Exception("Cannot unsubscribe to messages for unregistered account") object VerificationOfNewUser: Exception("Cannot verify a new (unregistered) account") object VerificationOfVerifiedUser: Exception("Cannot verify account that is already verified") diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/logic/AccountManager.kt b/signalc/src/main/kotlin/info/signalboost/signalc/logic/AccountManager.kt index ef6c6bfeb9bd984868e18790635997acb5d839ac..628c09a7a6bf603df97936aeef676eadf9561dc2 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/logic/AccountManager.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/logic/AccountManager.kt @@ -9,7 +9,10 @@ import info.signalboost.signalc.model.NewAccount import info.signalboost.signalc.model.RegisteredAccount import info.signalboost.signalc.model.VerifiedAccount import info.signalboost.signalc.util.KeyUtil -import kotlinx.coroutines.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext import mu.KLoggable import org.whispersystems.libsignal.util.guava.Optional import org.whispersystems.signalservice.api.SignalServiceAccountManager @@ -20,9 +23,8 @@ import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedE import org.whispersystems.signalservice.api.util.UptimeSleepTimer import org.whispersystems.signalservice.internal.push.VerifyAccountResponse import java.io.IOException -import java.util.UUID +import java.util.* import kotlin.io.path.ExperimentalPathApi -import kotlin.jvm.Throws import kotlin.time.ExperimentalTime @@ -72,20 +74,20 @@ class AccountManager(private val app: Application) { // register an account with signal server and request an sms token to use to verify it (storing account in db) suspend fun register(account: NewAccount, captcha: String? = null): RegisteredAccount { - app.coroutineScope.async(Concurrency.Dispatcher) { + withContext(app.coroutineScope.coroutineContext + Concurrency.Dispatcher) { accountManagerOf(account).requestSmsVerificationCode( false, captcha?.let { Optional.of(it) } ?: Optional.absent(), Optional.absent() ) - }.await() + } return RegisteredAccount.fromNew(account).also { accountStore.save(it) } } // provide a verification code, retrieve and store a UUID (storing account in db when done) suspend fun verify(account: RegisteredAccount, code: String): VerifiedAccount? { val verifyResponse: VerifyAccountResponse = try { - app.coroutineScope.async(Concurrency.Dispatcher) { + withContext(app.coroutineScope.coroutineContext + Concurrency.Dispatcher) { accountManagerOf(account).verifyAccountWithCode( code, null, @@ -98,7 +100,7 @@ class AccountManager(private val app: Application) { AccountAttributes.Capabilities(true, false, false, false), true ) - }.await() + } } catch(e: AuthorizationFailedException) { return null } @@ -106,11 +108,13 @@ class AccountManager(private val app: Application) { // TODO(aguestuser|2020-12-23): // - as a privacy matter, we might eventually want to throw away phone numbers once we have a UUID // - if so, consider udpating `accountId` in protocol store to this uuid at this point? - return VerifiedAccount.fromRegistered(account, uuid).also{ accountStore.save(it) } + return VerifiedAccount.fromRegistered(account, uuid).also{ + accountStore.save(it) + app.contactStore.createOwnContact(it) + } } - /** * generate prekeys, store them locally and publish them to signal **/ @@ -156,10 +160,10 @@ class AccountManager(private val app: Application) { **/ @Throws(IOException::class) // if network call to retreive sender cert fails suspend fun getUnidentifiedAccessPair(accountId: String, contactId: String): UnidentifiedAccessPair? { - val contactAccessKey = app.profileStore.loadProfileKey(accountId, contactId)?.let { + val contactAccessKey = app.contactStore.loadProfileKey(accountId, contactId)?.let { UnidentifiedAccess.deriveAccessKeyFrom(it) } ?: run { - logger.error { "Could not derive delivery token for $contactId: no profile key found." } + logger.warn { "Could not derive delivery token for $contactId: no profile key found." } return null } diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt index ecb25ba04f197e20d9abbf493b0694cf6137594e..dbbd3e866c3599f378427f6df5c03a1f73ee7ea7 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt @@ -16,18 +16,19 @@ import info.signalboost.signalc.util.FileUtil.readToFile import kotlinx.coroutines.* import mu.KLoggable import org.postgresql.util.Base64 +import org.signal.libsignal.metadata.ProtocolException import org.signal.libsignal.metadata.ProtocolUntrustedIdentityException import org.whispersystems.signalservice.api.SignalServiceMessagePipe import org.whispersystems.signalservice.api.SignalServiceMessageReceiver import org.whispersystems.signalservice.api.crypto.SignalServiceCipher import org.whispersystems.signalservice.api.messages.SignalServiceAttachment import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer -import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope import org.whispersystems.signalservice.api.util.UptimeSleepTimer import java.io.File import java.io.IOException import java.nio.file.Files +import java.util.* import java.util.concurrent.CancellationException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit @@ -44,6 +45,7 @@ import kotlin.time.ExperimentalTime class SignalReceiver(private val app: Application) { companion object: Any(), KLoggable { override val logger = logger() + private val metrics = Metrics.SignalReceiver private const val TMP_FILE_PREFIX = "___" private const val TMP_FILE_SUFFIX = ".tmp" private const val MAX_ATTACHMENT_SIZE = 150L * 1024 * 1024 // 150MB @@ -60,7 +62,6 @@ class SignalReceiver(private val app: Application) { // FIELDS/FACTORIES internal val messagesInFlight = AtomicInteger(0) - private val subscriptions = ConcurrentHashMap<String,Job>() private val messageReceivers = ConcurrentHashMap<String,SignalServiceMessageReceiver>() private val messagePipes = ConcurrentHashMap<String,SignalServiceMessagePipe>() @@ -166,90 +167,128 @@ class SignalReceiver(private val app: Application) { private suspend fun dispatch(account: VerifiedAccount, envelope: SignalServiceEnvelope): Job? { envelope.type.asEnum().let { logger.debug { "Got ${it.asString} from ${envelope.sourceIdentifier ?: "SEALED"} to ${account.username}" } - Metrics.SignalReceiver.numberOfMessagesReceived.labels(it.asString).inc() + metrics.numberOfMessagesReceived.labels(it.asString).inc() return when (it) { + EnvelopeType.CIPHERTEXT, + EnvelopeType.UNIDENTIFIED_SENDER -> { + processMessage(envelope, account) + } EnvelopeType.PREKEY_BUNDLE -> { - relay(envelope, account) - maybeRefreshPreKeys(account) + processPreKeyBundle(envelope, account) + processMessage(envelope, account) + } + EnvelopeType.RECEIPT -> { + processReceipt(envelope, account) + null + } + EnvelopeType.KEY_EXCHANGE, // TODO: handle KEY_EXCHANGE to process "reset secure session" events + EnvelopeType.UNKNOWN -> { + drop(envelope, account) + null } - - EnvelopeType.UNIDENTIFIED_SENDER, - EnvelopeType.CIPHERTEXT -> relay(envelope, account) - - EnvelopeType.KEY_EXCHANGE, // TODO: handle this to process "reset secure session" events - EnvelopeType.RECEIPT, // signal android basically drops these, so do we! - EnvelopeType.UNKNOWN -> drop(envelope, account) } - } } - private suspend fun relay(envelope: SignalServiceEnvelope, account: VerifiedAccount): Job { - // Attempt to decrypt envelope in a new coroutine then relay result to socket message sender for handling. - val (sender, recipient) = Pair(envelope.asSignalcAddress(), account.address) + /** + * Attempt to decrypt envelope and process data message then relay result to socket for handling by client. + */ + private suspend fun processMessage(envelope: SignalServiceEnvelope, account: VerifiedAccount): Job { return app.coroutineScope.launch(Concurrency.Dispatcher) { + var contactAddress: SignalcAddress? = null // not available until after decryption for sealed-sender msgs try { messagesInFlight.getAndIncrement() - val dataMessage: SignalServiceDataMessage = cipherOf(account).decrypt(envelope).dataMessage.orNull() - ?: return@launch // drop other message types (eg: typing message, sync message, etc) - val body = dataMessage.body?.orNull() ?: "" - val attachments = dataMessage.attachments.orNull() ?: emptyList() + // decrypt data message (returning early if not data message -- eg: typing notification, etc.) + val contents = cipherOf(account).decrypt(envelope) + val dataMessage = contents.dataMessage.orNull()?: return@launch + contactAddress = contents.sender.asSignalcAddress() + + // store sender's profile key if present and acknowledge message receipt to them dataMessage.profileKey.orNull()?.let { - app.profileStore.storeProfileKey(recipient.identifier, sender.identifier, it) + // TODO: we'd like to only make this db call if `dataMessage.isProfileKeyUpdate`, but that is flaky + app.contactStore.storeProfileKey(account.id, contactAddress.identifier, it) + } ?: run { + metrics.numberOfMessagesWithoutProfileKey.labels(envelope.isUnidentifiedSender.toString()).inc() + } + launch(Concurrency.Dispatcher) { + app.signalSender.sendReceipt(account, contactAddress, dataMessage.timestamp) } + // extract contents of message and relay to client + val body = dataMessage.body?.orNull() ?: "" // expiry timer changes contain empty message bodies + val attachments = dataMessage.attachments.orNull() ?: emptyList() app.socketSender.send( SocketResponse.Cleartext.of( - sender, - recipient, - body, // we allow empty message bodies b/c that's how expiry timer changes are sent + contactAddress, + account.address, + body, attachments.mapNotNull { it.retrieveFor(account) }, dataMessage.expiresInSeconds, dataMessage.timestamp, ) ) } catch(err: Throwable) { - handleRelayError(err, sender, recipient) + handleRelayError(err, account.address, contactAddress) } finally { messagesInFlight.getAndDecrement() } } } - private fun maybeRefreshPreKeys(account: VerifiedAccount) = app.coroutineScope.launch(Concurrency.Dispatcher) { - // If we are receiving a prekey bundle, this is the beginning of a new session, the initiation - // of which might have depleted our prekey reserves below the level we want to keep on hand - // to start new sessions. So: launch a background job to check our prekey reserve and replenish it if needed! - app.accountManager.refreshPreKeysIfDepleted(account) - } + private suspend fun processPreKeyBundle(envelope: SignalServiceEnvelope, account: VerifiedAccount) = + withContext(Concurrency.Dispatcher) { + // Prekey bundles are received (once per device) from new contacts before we initiate a sealed sender session + // with them. As such, this is our first (and only!) chance to store both the UUID and phone number + // that will be necessary to successfully decrypt subsequent messages in a session that may refer to the same + // user by *either* UUID or phone number (and will switch between the two during thes same session). + // We store both here so we can resolve either UUID or phone number to the same contact later. + // We also want new contacts to be able to send us sealed sender messages as soon as possible, so + // we send them our profile key, which enables sealed-sender message sending. + if(!app.contactStore.hasContact(account.id, envelope.sourceIdentifier)) { + app.contactStore.create(account, envelope) + app.signalSender.sendProfileKey(account, envelope.asSignalcAddress()) + } + // If we are receiving a prekey bundle, this is also the beginning of a new session, which consumes one of the + // recipient's one-time prekeys. Since this might have depleted the recipient's prekeys below the number needed + // to start new sessions, we launch a background job to check the prekey reserve and replenish it if needed! + app.accountManager.refreshPreKeysIfDepleted(account) + } + + private suspend fun processReceipt(envelope: SignalServiceEnvelope, account: VerifiedAccount) = + // An unsealed receipt is the first contact we will have if we initiated a conversation with a contact, so + // use the opportunity to store their UUID and phone number! (Note: all envelopes of type RECEIPT are unsealed.) + app.contactStore.storeMissingIdentifier( + accountId = account.id, + contactPhoneNumber = envelope.sourceE164.get(), + contactUuid = UUID.fromString(envelope.sourceUuid.get()), + ) + - private suspend fun drop(envelope: SignalServiceEnvelope, account: VerifiedAccount): Job? { + private suspend fun drop(envelope: SignalServiceEnvelope, account: VerifiedAccount) = app.socketSender.send( SocketResponse.Dropped(envelope.asSignalcAddress(), account.address, envelope) ) - return null - } - private suspend fun handleRelayError(err: Throwable, sender: SignalcAddress, recipient: SignalcAddress) { + private suspend fun handleRelayError(err: Throwable, account: SignalcAddress, decryptedContact: SignalcAddress?) { when (err) { is ProtocolUntrustedIdentityException -> { // When we get this exception we return a null fingerprint to the client, with the intention of causing // it to send a garbage message that will force a session reset and raise another identity exception. // That exception (unlike this one) will include the fingerprint corresponding to the new session, // and can be handled (and trusted) from the send path. - app.socketSender.send( - SocketResponse.InboundIdentityFailure.of( - recipient, - sender, - null, - ) - ) + val contactAddress = app.contactStore.getContactAddress(account.id, err.sender) + app.socketSender.send(SocketResponse.InboundIdentityFailure.of(account, contactAddress,null)) } - else -> { - app.socketSender.send(SocketResponse.DecryptionError(sender, recipient, err)) + is ProtocolException -> { + val contactAddress = app.contactStore.getContactAddress(account.id, err.sender) + app.socketSender.send(SocketResponse.DecryptionError(account, contactAddress, err)) logger.error { "Decryption Error:\n ${err.stackTraceToString()}" } } + else -> { + app.socketSender.send(SocketResponse.MessageHandlingError(account, decryptedContact, err)) + logger.error { "Error handling incoming message from signal:\n ${err.stackTraceToString()}" } + } } } diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt index e1e2a962d2ca14511c4d3d8a7628fb4b1a178418..007a5b67a7da394c91b415b64bae00088365a09b 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt @@ -10,6 +10,7 @@ import info.signalboost.signalc.model.SocketRequest import info.signalboost.signalc.model.VerifiedAccount import info.signalboost.signalc.util.CacheUtil.getMemoized import info.signalboost.signalc.util.TimeUtil +import info.signalboost.signalc.util.TimeUtil.nowInMillis import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.async @@ -20,10 +21,12 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException import org.whispersystems.signalservice.api.messages.SignalServiceAttachment import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage import java.io.File import java.io.IOException import java.io.InputStream import java.nio.file.Files +import java.time.Instant import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import kotlin.io.path.ExperimentalPathApi @@ -81,7 +84,7 @@ class SignalSender(private val app: Application) { Optional.absent(), // preview (we don't support those!) sendAttachment.width, sendAttachment.height, - TimeUtil.nowInMillis(), //uploadTimestamp + nowInMillis(), //uploadTimestamp Optional.fromNullable(sendAttachment.caption), Optional.fromNullable(sendAttachment.blurHash), null, // progressListener @@ -100,7 +103,7 @@ class SignalSender(private val app: Application) { body: String, expiration: Int, attachments: List<SocketRequest.Send.Attachment> = emptyList(), - timestamp: Long = TimeUtil.nowInMillis(), + timestamp: Long = nowInMillis(), ): SignalcSendResult = // TODO: handle `signalservice.api.push.exceptions.NotFoundException` here sendDataMessage( @@ -118,6 +121,30 @@ class SignalSender(private val app: Application) { .build() ) + suspend fun sendProfileKey( + sender: VerifiedAccount, + recipient: SignalcAddress, + timestamp: Long = nowInMillis(), + ): SignalcSendResult = + sendDataMessage( + sender, + recipient, + SignalServiceDataMessage + .newBuilder() + .asProfileKeyUpdate(true) + .withTimestamp(timestamp) + .withProfileKey(sender.profileKeyBytes) + .build() + ) + + /* send a read receipt (so sender sees "2 checks" -- but only if we can send it as a sealed sender message */ + suspend fun sendReceipt(sender: VerifiedAccount, recipient: SignalcAddress, timestamp: Long) = + messageSenderOf(sender).sendReceipt( + recipient.asSignalServiceAddress(), + Optional.fromNullable(app.accountManager.getUnidentifiedAccessPair(sender.id, recipient.identifier)), + SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ, listOf(timestamp), timestamp), + ) + suspend fun setExpiration( sender: VerifiedAccount, recipient: SignalcAddress, @@ -153,8 +180,8 @@ class SignalSender(private val app: Application) { // Try to send all messages sealed-sender by deriving `unidentifiedAccessPair`s from profile keys. Without such // tokens, message are sent unsealed, and are treated by Signal as spam. To avoid being blocked by Signal, // we expose a toggle to prevent all unsealed messages from being sent. - val unidentifiedAccessPair = app.accountManager.getUnidentifiedAccessPair(sender.identifier,recipient.identifier).also { - metrics.numberOfUnsealedMessagesProduced.labels(sender.identifier).inc() + val unidentifiedAccessPair = app.accountManager.getUnidentifiedAccessPair(sender.id, recipient.identifier).also { + metrics.numberOfUnsealedMessagesProduced.labels(sender.id).inc() if(it == null && app.toggles.blockUnsealedMessages) return@async SignalcSendResult.Blocked(recipient) } try { diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt b/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt index 0353ef7d902590a67eb65cc30816209654b84ab6..a9eec56d5a2c4a4329df6555d288c3e3f461148a 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt @@ -64,10 +64,20 @@ object Metrics { object SignalReceiver { val numberOfMessagesReceived: Counter = counterOf( "signal_receiver__number_of_messages_received", - "Counts number of inbound PREKEY_BUNDLE messages we receive from signal server when users try to establish new sessions." + - "If we often receive a high number of these in quick succession, consider throttling prekey replenish jobs.", + "Counts number of inbound messages received from signal server and categorizes them by type", "envelope_type", ) + + val numberOfMessagesWithoutProfileKey = counterOf( + "signal_receiver__number_of_messages_without_profile_key", + "Counts number of messages received without profile key. " + + "The 'is_sealed_sender' label allow us to track unsealed messages without profile keys " + + "which are worrisome because this is the signature of sessions initiated from a desktop client " + + "but not confirmed on a phone by tapping 'continue'. Without such confirmation, we will " + + "never be able to initiate sealed sender sessions, and thus risk getting blocked " + + "if the number of such messages gets too high.", + "is_sealed_sender" + ) } object SignalSender { diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/model/Account.kt b/signalc/src/main/kotlin/info/signalboost/signalc/model/Account.kt index 645b0a31637ee1b6ba607df18e7dd211fed5e10f..cb71f6bfce948836b7242682ed542bb9e282b83f 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/model/Account.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/model/Account.kt @@ -196,6 +196,11 @@ data class VerifiedAccount( } } + // accessor that returns what signalboost (currently) uses: a phone number + val id: String + get() = username + + // accessor that returns what signalservice address would return: a uuid val identifier: String get() = uuid.toString() diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/model/SignalcAddress.kt b/signalc/src/main/kotlin/info/signalboost/signalc/model/SignalcAddress.kt index 339ae0cad8ad91b3b725cf70862e2b7c84cf2ccf..abf383d903d5c1b28f39e2c12235e059186324a9 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/model/SignalcAddress.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/model/SignalcAddress.kt @@ -34,6 +34,10 @@ data class SignalcAddress( } } } + + val id: String + get() = number!! + val identifier: String get() = uuid?.toString() ?: number!! diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/model/SocketResponse.kt b/signalc/src/main/kotlin/info/signalboost/signalc/model/SocketResponse.kt index 1d8f8033cb43486b20df74e13e34e5f804e43e79..d260cf930caad2c6b055cf37df60a2b7cd99d983 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/model/SocketResponse.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/model/SocketResponse.kt @@ -108,12 +108,12 @@ sealed class SocketResponse { } } - // TODO: what does signald do here? @Serializable @SerialName("decryption_error") data class DecryptionError( - val sender: SignalcAddress, val recipient: SignalcAddress, + @Required + val sender: SignalcAddress?, @Serializable(ThrowableSerializer::class) val error: Throwable, ): SocketResponse() @@ -130,7 +130,8 @@ sealed class SocketResponse { @Serializable data class Data( val local_address: LocalAddress, - val remote_address: RemoteAddress, + @Required + val remote_address: RemoteAddress?, @Required val fingerprint: String? ) @@ -144,11 +145,27 @@ sealed class SocketResponse { data class RemoteAddress(val number: String) companion object { - fun of(localAddress: SignalcAddress, remoteAddress: SignalcAddress, fingerprint: String? = null) = - InboundIdentityFailure(Data(LocalAddress(localAddress.number!!), RemoteAddress(remoteAddress.number!!), fingerprint)) + fun of(localAddress: SignalcAddress, remoteAddress: SignalcAddress?, fingerprint: String? = null) = + InboundIdentityFailure( + Data( + LocalAddress(localAddress.number!!), + remoteAddress?.let{ RemoteAddress(it.number!!) }, + fingerprint + ) + ) } } + @Serializable + @SerialName("message_handling_error") + data class MessageHandlingError( + val recipient: SignalcAddress, + @Required + val sender: SignalcAddress?, + @Serializable(ThrowableSerializer::class) + val error: Throwable, + ): SocketResponse() + @Serializable @SerialName("registration_succeeded") data class RegistrationSuccess( @@ -197,7 +214,6 @@ sealed class SocketResponse { } } - // TODO: what does signald do here? "unrecognized"? @Serializable @SerialName("request_invalid") // TODO: invalidRequest data class RequestInvalidError( diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt new file mode 100644 index 0000000000000000000000000000000000000000..7ab8354316e732bdcfb760b893c44beee209ed65 --- /dev/null +++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt @@ -0,0 +1,173 @@ +package info.signalboost.signalc.store + +import info.signalboost.signalc.Application +import info.signalboost.signalc.db.ContactRecord.Companion.findByContactId +import info.signalboost.signalc.db.ContactRecord.Companion.updateByContactId +import info.signalboost.signalc.db.Contacts +import info.signalboost.signalc.dispatchers.Concurrency +import info.signalboost.signalc.model.SignalcAddress +import info.signalboost.signalc.model.VerifiedAccount +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.runBlocking +import mu.KLoggable +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import org.jetbrains.exposed.sql.transactions.transaction +import org.signal.zkgroup.profiles.ProfileKey +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope +import java.lang.IllegalStateException +import java.util.UUID +import kotlin.io.path.ExperimentalPathApi +import kotlin.time.ExperimentalTime + +@ExperimentalCoroutinesApi +@ObsoleteCoroutinesApi +@ExperimentalPathApi +@ExperimentalTime +class ContactStore(app: Application) { + + companion object: Any(), KLoggable { + override val logger = logger() + private const val VALID_PROFILE_KEY_SIZE = 32 + } + + val dispatcher = Concurrency.Dispatcher + val db = app.db + + suspend fun create(accountId: String, phoneNumber: String?, uuid: UUID?, profileKey: ByteArray? = null): Int = + if (phoneNumber == null && uuid == null) + throw IllegalStateException("Cannot create contact w/o phone number or uuid") + else newSuspendedTransaction(dispatcher, db) { + Contacts.insert { + it[Contacts.accountId] = accountId + it[Contacts.phoneNumber] = phoneNumber + it[Contacts.uuid] = uuid + it[profileKeyBytes] = profileKey + } + }.resultedValues!!.single()[Contacts.contactId] + + suspend fun create(account: VerifiedAccount, envelope: SignalServiceEnvelope): Int = + create(account.id, envelope.sourceE164.orNull(), envelope.sourceUuid.orNull()?.let { UUID.fromString(it) } ) + + suspend fun createOwnContact(account: VerifiedAccount) = with(account) { + create(accountId = username, phoneNumber = username, uuid = uuid, profileKey = profileKeyBytes) + } + + /** + * Given a string identifier that may be either a uuid or a phone number, + * Report whether a contact exists containing either that uuid or phone number. + */ + suspend fun hasContact(accountId: String, contactIdentifier: String): Boolean = + newSuspendedTransaction(Concurrency.Dispatcher, db) { + Contacts.select { + (Contacts.accountId eq accountId).and( + (Contacts.phoneNumber eq contactIdentifier).or(Contacts.uuid eq parseUuid(contactIdentifier)) + ) + }.count() > 0 + } + + /** + * Given a string identifier that may be either a uuid or a phone number, + * Retrieve a contact having either such identifier and convert it into a SignalcAddress + */ + suspend fun getContactAddress(accountId: String, contactIdentifier: String): SignalcAddress? = + newSuspendedTransaction(Concurrency.Dispatcher, db) { + Contacts.select { + (Contacts.accountId eq accountId).and( + (Contacts.uuid eq parseUuid(contactIdentifier)).or(Contacts.phoneNumber eq contactIdentifier) + ) + }.singleOrNull() + }?.let { SignalcAddress(uuid = it[Contacts.uuid], number = it[Contacts.phoneNumber]) } + + /** + * Given a string identifier -- which may be either a uuid or a phone number -- retrieve the contact having + * that uuid or phone number if it exists or create a new contact if it does not exist. + * This function accepts a Transaction as an argument so that it may be called from either a blocking or + * suspend contexts. Since most callers will be signal protocol store functions (which are all blocking), + * the argument defaults to a blocking transaction. + **/ + fun resolveContactId(accountId: String, contactIdentifier: String, tx: Transaction? = null): Int { + val uuid = parseUuid(contactIdentifier) + val statement = { + run { + uuid + ?.let { Contacts.select { (Contacts.accountId eq accountId).and(Contacts.uuid eq it) } } + ?: Contacts.select { (Contacts.accountId eq accountId).and(Contacts.phoneNumber eq contactIdentifier) } + }.singleOrNull() + ?.let { it[Contacts.contactId] } + ?: runBlocking { + if (uuid == null) create(accountId, contactIdentifier, null) + else create(accountId, null, uuid) + } + } + return tx?.let { statement() } ?: transaction(db) { statement() } + } + + /** + * Given a uuid and a phone number, store the uuid if we already have the phone number, or the phone number + * if we already have the uuid. Since most contacts start with a phone number, attempt to store the uuid first + * and return early if we succeed. Throw if we cannot find a contact with either uuid or phone number. + **/ + suspend fun storeMissingIdentifier(accountId: String, contactPhoneNumber: String, contactUuid: UUID) { + newSuspendedTransaction(dispatcher, db) { + + Contacts.update({ + (Contacts.accountId eq accountId).and(Contacts.phoneNumber eq contactPhoneNumber) + }){ + it[uuid] = contactUuid + }.let { + if(it > 0) return@newSuspendedTransaction + } + + Contacts.update({ + (Contacts.accountId eq accountId).and(Contacts.uuid eq contactUuid) + }){ + it[phoneNumber] = contactPhoneNumber + }.let { + if(it > 0) return@newSuspendedTransaction + } + + throw IllegalStateException( + "Can't store missing identifier without a known identifier- PN: $contactPhoneNumber, UUID: $contactUuid" + ) + } + } + + suspend fun storeProfileKey(accountId: String, contactIdentifier: String, profileKey: ByteArray) { + if (profileKey.size != VALID_PROFILE_KEY_SIZE) { + logger.warn { "Received profile key of invalid size ${profileKey.size} for account: $contactIdentifier" } + return + } + return newSuspendedTransaction(dispatcher, db) { + val contactId = resolveContactId(accountId, contactIdentifier, this) + Contacts.updateByContactId(accountId, contactId) { + it[Contacts.profileKeyBytes] = profileKey + } + } + } + + suspend fun loadProfileKey(accountId: String, contactIdentifier: String): ProfileKey? = + newSuspendedTransaction(dispatcher, db) { + val contactId = resolveContactId(accountId, contactIdentifier, this) + Contacts.findByContactId(accountId, contactId)?.let { resultRow -> + resultRow[Contacts.profileKeyBytes]?.let{ ProfileKey(it) } + } + } + + // HELPERS + + private fun parseUuid(identifier: String): UUID? = + try { UUID.fromString(identifier) } catch(ignored: Throwable) { null } + + // TESTING FUNCTIONS + internal suspend fun count(): Long = newSuspendedTransaction(dispatcher, db) { + Contacts.selectAll().count() + } + + internal suspend fun deleteAllFor(accountId: String) = newSuspendedTransaction(dispatcher, db) { + Contacts.deleteWhere { + Contacts.accountId eq accountId + } + } +} \ No newline at end of file diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/ProfileStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/ProfileStore.kt deleted file mode 100644 index b45a451501b4a8691aa9119b5f43bd158ffd2a2c..0000000000000000000000000000000000000000 --- a/signalc/src/main/kotlin/info/signalboost/signalc/store/ProfileStore.kt +++ /dev/null @@ -1,67 +0,0 @@ -package info.signalboost.signalc.store - -import info.signalboost.signalc.Application -import info.signalboost.signalc.db.Profiles -import info.signalboost.signalc.dispatchers.Concurrency -import info.signalboost.signalc.serialization.ByteArrayEncoding.toPostgresHex -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.ObsoleteCoroutinesApi -import mu.KLoggable -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction -import org.signal.zkgroup.profiles.ProfileKey -import kotlin.io.path.ExperimentalPathApi -import kotlin.time.ExperimentalTime - -@ExperimentalCoroutinesApi -@ObsoleteCoroutinesApi -@ExperimentalPathApi -@ExperimentalTime -class ProfileStore(app: Application) { - companion object: Any(), KLoggable { - override val logger = logger() - private const val VALID_PROFILE_KEY_SIZE = 32 - } - - val dispatcher = Concurrency.Dispatcher - val db = app.db - - suspend fun storeProfileKey(accountId: String, contactId: String, profileKey: ByteArray): Unit { - if (profileKey.size != VALID_PROFILE_KEY_SIZE) { - logger.warn { "Received profile key of invalid size ${profileKey.size} for account: $accountId" } - return - } - return newSuspendedTransaction(dispatcher, db) { - exec( - "INSERT into profiles (account_id, contact_id, profile_key_bytes) " + - "VALUES('$accountId','$contactId', ${profileKey.toPostgresHex()}) " + - "ON CONFLICT (account_id, contact_id)" + - "DO UPDATE set profile_key_bytes = EXCLUDED.profile_key_bytes;" - ) - } - } - - suspend fun loadProfileKey(accountId: String, contactId: String): ProfileKey? = - newSuspendedTransaction(dispatcher, db) { - Profiles.select { - (Profiles.accountId eq accountId).and( - Profiles.contactId eq contactId - ) - }.singleOrNull()?.let { - ProfileKey(it[Profiles.profileKeyBytes]) - } - } - - // TESTING FUNCTIONS - internal suspend fun count(): Long = newSuspendedTransaction(dispatcher, db) { - Profiles.selectAll().count() - } - - internal suspend fun deleteAllFor(accountId: String) = newSuspendedTransaction(dispatcher, db) { - Profiles.deleteWhere { - Profiles.accountId eq accountId - } - } - - -} \ No newline at end of file diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/ProtocolStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/ProtocolStore.kt index 4e0573e8bd4dc77a119a1963bc2b8096299556fa..88c6426542ba4b6d5336cc8e4f9622953cb1d3da 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/store/ProtocolStore.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/ProtocolStore.kt @@ -14,7 +14,6 @@ import org.whispersystems.libsignal.groups.state.SenderKeyStore import org.whispersystems.libsignal.state.* import org.whispersystems.signalservice.api.SignalServiceProtocolStore import org.whispersystems.signalservice.api.SignalServiceSessionStore -import org.whispersystems.signalservice.api.push.SignalServiceAddress import java.time.Instant import kotlin.io.path.ExperimentalPathApi import kotlin.time.ExperimentalTime @@ -24,21 +23,31 @@ import kotlin.time.ExperimentalTime @ObsoleteCoroutinesApi @ExperimentalPathApi @ExperimentalTime -class ProtocolStore(app: Application) { +class ProtocolStore(val app: Application) { val db = app.db - fun of(account: Account): AccountProtocolStore = AccountProtocolStore(db, account.username) + fun of(account: Account): AccountProtocolStore = AccountProtocolStore( + db, + account.username, + app.contactStore::resolveContactId + ) fun countOwnIdentities(): Long = transaction(db) { OwnIdentities.selectAll().count() } + /** + * Implements the 4 stores required by the SignalServiceProtocolSTore interface and some decorator functions used + * only by signalc. Each instance of this class provides a protocol store for a single account. Since any instance + * of signalc has many accounts, any singalc instance will also have many protocol stores. + **/ class AccountProtocolStore( private val db: Database, private val accountId: String, + private val resolveContactId: (String, String) -> Int, val lock: SessionLock = SessionLock(), - private val identityStore: IdentityKeyStore = SignalcIdentityStore(db, accountId, lock), + private val identityStore: IdentityKeyStore = SignalcIdentityStore(db, accountId, lock, resolveContactId), private val preKeyStore: PreKeyStore = SignalcPreKeyStore(db, accountId, lock), private val senderKeyStore: SenderKeyStore = SignalcSenderKeyStore(db, accountId, lock), - private val sessionStore: SignalServiceSessionStore = SignalcSessionStore(db, accountId, lock), + private val sessionStore: SignalServiceSessionStore = SignalcSessionStore(db, accountId, lock, resolveContactId), private val signedPreKeyStore: SignedPreKeyStore = SignalcSignedPreKeyStore(db, accountId, lock), ) : SignalServiceProtocolStore, IdentityKeyStore by identityStore, @@ -47,9 +56,7 @@ class ProtocolStore(app: Application) { SignalServiceSessionStore by sessionStore, SignedPreKeyStore by signedPreKeyStore { - /** - * DECORATOR FUNCTIONS - **/ + // DECORATOR FUNCTIONS private val scIdentityStore = identityStore as SignalcIdentityStore val countIdentitities: () -> Long = scIdentityStore::countIdentities diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcIdentityStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcIdentityStore.kt index 19f55a2e54f679af2207843f61a496db96e47c47..6827e049e81c64afe80d44c94aea9abe5132610b 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcIdentityStore.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcIdentityStore.kt @@ -25,6 +25,7 @@ class SignalcIdentityStore( val db: Database, val accountId: String, val lock: SessionLock, + val resolveContactId: (String, String) -> Int, ): IdentityKeyStore{ override fun getIdentityKeyPair(): IdentityKeyPair = @@ -57,8 +58,9 @@ class SignalcIdentityStore( @Throws(SignalcError.UpdateToNonExistentFingerprint::class) fun trustFingerprint(address: SignalProtocolAddress, fingerprint: ByteArray) { lock.acquireForTransaction(db) { - rejectUnknownFingerprint(address, fingerprint) - Identities.updateByContactId(accountId, address.name) { + val contactId = resolveContactId(accountId, address.name) + rejectUnknownFingerprint(contactId, fingerprint) + Identities.updateByContactId(accountId, contactId) { it[isTrusted] = true it[updatedAt] = Instant.now() } @@ -68,22 +70,22 @@ class SignalcIdentityStore( @Throws(SignalcError.UpdateToNonExistentFingerprint::class) fun untrustFingerprint(address: SignalProtocolAddress, fingerprint: ByteArray) { lock.acquireForTransaction(db) { - rejectUnknownFingerprint(address, fingerprint) - Identities.updateByContactId(accountId, address.name) { + val contactId = resolveContactId(accountId, address.name) + rejectUnknownFingerprint(contactId, fingerprint) + Identities.updateByContactId(accountId, contactId) { it[isTrusted] = false it[updatedAt] = Instant.now() } } } - private fun rejectUnknownFingerprint(address: SignalProtocolAddress, fingerprint: ByteArray) { + private fun rejectUnknownFingerprint(contactId: Int, fingerprint: ByteArray) { transaction(db) { - val contactId = address.name val knownFingerprint = Identities.findByContactId(accountId, contactId)?.let { it[identityKeyBytes] } if (!fingerprint.contentEquals(knownFingerprint)) - throw SignalcError.UpdateToNonExistentFingerprint(contactId, fingerprint) + throw SignalcError.UpdateToNonExistentFingerprint(accountId, contactId, fingerprint) } } @@ -93,7 +95,7 @@ class SignalcIdentityStore( // - deny trust for subsequent identity keys for same address // Returns true if this save was an update to an existing record, false otherwise lock.acquireForTransaction(db) { - val contactId = address.name + val contactId = resolveContactId(accountId, address.name) Identities.findByContactId(accountId, contactId) ?.let { existingKey -> Identities.updateByContactId(accountId, contactId) { @@ -121,7 +123,7 @@ class SignalcIdentityStore( ): Boolean = lock.acquireForTransaction(db) { // trust a key if... - Identities.findByContactId(accountId, address.name)?.let { + Identities.findByContactId(accountId, resolveContactId(accountId, address.name))?.let { // it matches a key we have seen before it[identityKeyBytes] contentEquals identityKey.serialize() && // and we have not flagged it as untrusted @@ -131,14 +133,14 @@ class SignalcIdentityStore( override fun getIdentity(address: SignalProtocolAddress): IdentityKey? = lock.acquireForTransaction(db) { - Identities.findByContactId(accountId, address.name)?.let { + Identities.findByContactId(accountId, resolveContactId(accountId, address.name))?.let { IdentityKey(it[identityKeyBytes], 0) } } fun removeIdentity(address: SignalProtocolAddress) { lock.acquireForTransaction(db) { - Identities.deleteByContactId(accountId, address.name) + Identities.deleteByContactId(accountId, resolveContactId(accountId, address.name)) } } @@ -158,8 +160,7 @@ class SignalcIdentityStore( fun whenIdentityLastUpdated(address: SignalProtocolAddress): Instant? = transaction(db) { - val contactId = address.name - Identities.findByContactId(accountId, contactId)?.let { + Identities.findByContactId(accountId, resolveContactId(accountId, address.name))?.let { it[updatedAt] } } diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcSessionStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcSessionStore.kt index d09a1d2ad695c49030f09de889c7d5ac57c837ed..4423a5fd3b3684a49ed88aeb1267ca07868d6b59 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcSessionStore.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/protocol/SignalcSessionStore.kt @@ -1,10 +1,11 @@ package info.signalboost.signalc.store.protocol import info.signalboost.signalc.db.* +import info.signalboost.signalc.db.ContactRecord.Companion.deleteByContactId import info.signalboost.signalc.db.ContactRecord.Companion.findManyByContactId -import info.signalboost.signalc.db.DeviceRecord.Companion.deleteByAddress -import info.signalboost.signalc.db.DeviceRecord.Companion.findByAddress -import info.signalboost.signalc.db.DeviceRecord.Companion.updateByAddress +import info.signalboost.signalc.db.DeviceRecord.Companion.deleteByDeviceId +import info.signalboost.signalc.db.DeviceRecord.Companion.findByDeviceId +import info.signalboost.signalc.db.DeviceRecord.Companion.updateByDeviceId import info.signalboost.signalc.db.Sessions.sessionBytes import org.jetbrains.exposed.sql.* import org.whispersystems.libsignal.SignalProtocolAddress @@ -16,11 +17,13 @@ class SignalcSessionStore( val db: Database, val accountId: String, val lock: SessionLock, + val resolveContactId: (String, String) -> Int, ): SignalServiceSessionStore { override fun loadSession(address: SignalProtocolAddress): SessionRecord = lock.acquireForTransaction(db) { - Sessions.findByAddress(accountId, address)?.let { + val contactId = resolveContactId(accountId, address.name) + Sessions.findByDeviceId(accountId, contactId, address.deviceId)?.let { SessionRecord(it[sessionBytes]) } ?: SessionRecord() } @@ -28,34 +31,35 @@ class SignalcSessionStore( override fun getSubDeviceSessions(name: String): MutableList<Int> = lock.acquireForTransaction(db) { Sessions.select { - Sessions.accountId eq accountId and (Sessions.contactId eq name) + (Sessions.accountId eq accountId).and(Sessions.contactId eq resolveContactId(accountId, name)) }.mapTo(mutableListOf()) { it[Sessions.deviceId] } } override fun storeSession(address: SignalProtocolAddress, record: SessionRecord) { // upsert the session record for a given address lock.acquireForTransaction(db) { - Sessions.updateByAddress(accountId, address) { - it[sessionBytes] = record.serialize() - }.let { numUpdated -> - if (numUpdated == 0) { - Sessions.insert { - it[accountId] = this@SignalcSessionStore.accountId - it[contactId] = address.name - it[deviceId] = address.deviceId - it[sessionBytes] = record.serialize() - } + val contactId = resolveContactId(accountId, address.name) + Sessions.updateByDeviceId(accountId, contactId, address.deviceId) { + it[sessionBytes] = record.serialize() + }.let { numUpdated -> + if (numUpdated == 0) { + Sessions.insert { + it[accountId] = this@SignalcSessionStore.accountId + it[Sessions.contactId] = contactId + it[deviceId] = address.deviceId + it[sessionBytes] = record.serialize() } } } } + } override fun containsSession(address: SignalProtocolAddress): Boolean = lock.acquireForTransaction(db) { - Sessions.findByAddress(accountId, address)?.let { + val contactId = resolveContactId(accountId, address.name) + Sessions.findByDeviceId(accountId, contactId, address.deviceId)?.let { val sessionRecord = SessionRecord(it[sessionBytes]) - sessionRecord.hasSenderChain() - && sessionRecord.sessionVersion == CiphertextMessage.CURRENT_VERSION; + sessionRecord.hasSenderChain() && sessionRecord.sessionVersion == CiphertextMessage.CURRENT_VERSION } ?: false } @@ -63,22 +67,22 @@ class SignalcSessionStore( override fun deleteSession(address: SignalProtocolAddress) { lock.acquireForTransaction(db) { - Sessions.deleteByAddress(accountId, address) + val contactId = resolveContactId(accountId, address.name) + Sessions.deleteByDeviceId(accountId, contactId, address.deviceId) } } override fun deleteAllSessions(name: String) { lock.acquireForTransaction(db) { - Sessions.deleteWhere { - Sessions.accountId eq accountId and (Sessions.contactId eq name) - } + Sessions.deleteByContactId(accountId, resolveContactId(accountId, name)) } } override fun archiveSession(address: SignalProtocolAddress) { lock.acquireForTransaction(db) { - Sessions.findByAddress(accountId, address)?.let { + val contactId = resolveContactId(accountId, address.name) + Sessions.findByDeviceId(accountId, contactId, address.deviceId)?.let { val session = SessionRecord(it[sessionBytes]) session.archiveCurrentState() storeSession(address, session) @@ -88,7 +92,7 @@ class SignalcSessionStore( fun archiveAllSessions(address: SignalProtocolAddress) { lock.acquireForTransaction(db) { - val contactId = address.name + val contactId = resolveContactId(accountId, address.name) Sessions.findManyByContactId(accountId, contactId).forEach { val session = SessionRecord(it[sessionBytes]) session.archiveCurrentState() @@ -96,6 +100,4 @@ class SignalcSessionStore( } } } - - } \ No newline at end of file diff --git a/signalc/src/main/resources/logback.xml b/signalc/src/main/resources/logback.xml index fcfd0fbbe9378f78006c9295fbf848cd80101261..54a6b0865425e53c299a34fc37c822e5b50cf29f 100644 --- a/signalc/src/main/resources/logback.xml +++ b/signalc/src/main/resources/logback.xml @@ -22,6 +22,10 @@ <appender-ref ref="stdout" /> </logger> + <logger name="info.signalboost.signalc.logging.LibSignalLogger" level="error" additivity="false"> + <appender-ref ref="stdout" /> + </logger> + <logger name="io.netty" level="info" additivity="false"> <appender-ref ref="stdout" /> </logger> diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/AccountManagerTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/AccountManagerTest.kt index a05427a9c071567ba7d0e4d16af2b8bc49e8d6bc..afd3adf7e4f6c7579f5827cd6291f77ec7d017ca 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/AccountManagerTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/AccountManagerTest.kt @@ -107,8 +107,6 @@ class AccountManagerTest : FreeSpec({ "#verify" - { val code = "1312" - val saveSlot = slot<VerifiedAccount>() - coEvery { app.accountStore.save(account = capture(saveSlot)) } returns Unit every { mockProtocolStore.localRegistrationId } returns 42 "when given correct code" - { @@ -132,9 +130,15 @@ class AccountManagerTest : FreeSpec({ "updates the account store" { accountManager.verify(registeredAccount, code) coVerify { - app.accountStore.save(ofType(VerifiedAccount::class)) + app.accountStore.save(verifiedAccount) + } + } + + "saves a self-referrential contact" { + accountManager.verify(registeredAccount, code) + coVerify { + app.contactStore.createOwnContact(verifiedAccount) } - saveSlot.captured shouldBe verifiedAccount } "returns a verified account" { @@ -293,7 +297,7 @@ class AccountManagerTest : FreeSpec({ } returns senderCert.serialized coEvery { - app.profileStore.loadProfileKey(verifiedAccount.identifier, any()) + app.contactStore.loadProfileKey(verifiedAccount.identifier, any()) } coAnswers { if (secondArg<String>() == knownContactId) contactProfileKey else null diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt index 284eed27ef3d37112ba2152824608bf1477b43ec..639a9190da030bb499e04d9e5f1fca036ad50a75 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt @@ -38,6 +38,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope.Type.* import java.io.IOException +import java.util.* import java.util.concurrent.TimeoutException import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.Path @@ -68,7 +69,7 @@ class SignalReceiverTest : FreeSpec({ // val senderAddress = genSignalServiceAddress() val senderAddress = genSignalcAddress() - val timeout = Duration.milliseconds(40) + val timeout = Duration.milliseconds(20) val pollInterval = Duration.milliseconds(1) val now = nowInMillis() val expiryTime = genInt() @@ -118,8 +119,10 @@ class SignalReceiverTest : FreeSpec({ every { anyConstructed<SignalServiceCipher>().decrypt(any()) } returns mockk { + every { sender } returns senderAddress.asSignalServiceAddress() every { dataMessage.orNull() } returns mockDataMessage.apply { every { body.orNull() } returnsMany cleartexts + } } @@ -334,16 +337,21 @@ class SignalReceiverTest : FreeSpec({ val identityKey = KeyUtil.genIdentityKeyPair().publicKey val untrustedIdentityError = ProtocolUntrustedIdentityException( UntrustedIdentityException(recipientAccount.username, identityKey), - senderAddress.asSignalServiceAddress().identifier, + senderAddress.identifier, genDeviceId() ) + every { anyConstructed<SignalServiceCipher>().decrypt(any()) } throws untrustedIdentityError - beforeTest { messageReceiver.subscribe(recipientAccount)!! } + coEvery { + app.contactStore.getContactAddress(recipientAccount.username, senderAddress.identifier) + } returns senderAddress + "relays InboundIdentityFailure to socket sender" { + messageReceiver.subscribe(recipientAccount)!! eventually(timeout, pollInterval) { coVerify { app.socketSender.send( @@ -419,6 +427,7 @@ class SignalReceiverTest : FreeSpec({ every { anyConstructed<SignalServiceCipher>().decrypt(any()) } returns mockk { + every { sender } returns senderAddress.asSignalServiceAddress() every { dataMessage.orNull() } returns mockDataMessage.apply { every { profileKey.orNull() } returns fakeProfileKey } @@ -426,10 +435,10 @@ class SignalReceiverTest : FreeSpec({ "stores the profile key" { messageReceiver.subscribe(recipientAccount) - eventually(timeout) { + eventually(timeout, pollInterval) { coVerify { - app.profileStore.storeProfileKey( - recipientAccount.address.identifier, + app.contactStore.storeProfileKey( + recipientAccount.address.id, senderAddress.identifier, fakeProfileKey, ) @@ -463,6 +472,7 @@ class SignalReceiverTest : FreeSpec({ every { anyConstructed<SignalServiceCipher>().decrypt(any()) } returns mockk { + every { sender } returns senderAddress.asSignalServiceAddress() every { dataMessage.orNull() } returns mockDataMessage.apply { every { body.orNull() } returns cleartextBody every { attachments.orNull() } returns listOf( @@ -541,6 +551,47 @@ class SignalReceiverTest : FreeSpec({ } } } + + "when the receiving account does has not stored a contact for the sender" - { + coEvery { + app.contactStore.hasContact(recipientAccount.id, envelope.sourceIdentifier) + } returns false + + "creates a contact for the sender" { + messageReceiver.subscribe(recipientAccount) + eventually(timeout, pollInterval) { + coVerify { + app.contactStore.create(recipientAccount, envelope) + } + } + } + + "sends the contact a profile key" { + messageReceiver.subscribe(recipientAccount) + eventually(timeout * 4, pollInterval) { + coVerify { + app.signalSender.sendProfileKey(recipientAccount, any()) + } + } + } + } + } + + "when signal sends a RECEIPT" - { + val (envelope) = signalSendsEnvelopeOf(RECEIPT_VALUE) + + "stores an missing identifiers contained in the receipt" { + messageReceiver.subscribe(recipientAccount) + eventually(timeout, pollInterval) { + coVerify { + app.contactStore.storeMissingIdentifier( + recipientAccount.id, + envelope.sourceE164.get(), + UUID.fromString(envelope.sourceUuid.get()), + ) + } + } + } } "when issued for an account that is already subscribed" - { diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt index 865baa77aec2789ff81fa00d6ef48c0de48b989e..e1f8dec6b863e38d6016c06e3b5c05b3642d1cf0 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt @@ -14,8 +14,10 @@ import info.signalboost.signalc.testSupport.dataGenerators.FileGen.genJpegFile import info.signalboost.signalc.testSupport.dataGenerators.SocketRequestGen.genSendAttachment import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalDataMessage import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalExpirationUpdate +import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalReceiptMessage import info.signalboost.signalc.util.KeyUtil.genUuidStr import info.signalboost.signalc.util.TimeUtil +import info.signalboost.signalc.util.TimeUtil.nowInMillis import io.kotest.assertions.timing.eventually import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.should @@ -33,6 +35,7 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair import org.whispersystems.signalservice.api.messages.SendMessageResult import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage import org.whispersystems.signalservice.api.push.SignalServiceAddress import java.io.File import java.io.InputStream @@ -60,8 +63,24 @@ class SignalSenderTest : FreeSpec({ val app = Application(config).run(testScope) val verifiedAccount = genVerifiedAccount() + val contactPhoneNumber = genPhoneNumber() val timeout = Duration.milliseconds(5) + val mockSuccess = mockk<SendMessageResult.Success> { + every { isNeedsSync } returns true + every { isUnidentified } returns false + every { duration } returns 0L + } + + fun sendingSucceeds() { + every { + anyConstructed<SignalServiceMessageSender>().sendMessage(any(), any(), any()) + } returns mockk { + every { address } returns contactPhoneNumber.asSignalServiceAddress() + every { success } returns mockSuccess + } + } + beforeSpec { mockkObject(TimeUtil) mockkConstructor(SignalServiceMessageSender::class) @@ -114,14 +133,7 @@ class SignalSenderTest : FreeSpec({ } } - val mockSuccess = mockk<SendMessageResult.Success> { - every { isNeedsSync } returns true - every { isUnidentified } returns false - every { duration } returns 0L - } - "#send" - { - val recipientPhone = genPhoneNumber() val dataMessageSlot = slot<SignalServiceDataMessage>() every { anyConstructed<SignalServiceMessageSender>().sendMessage( @@ -130,14 +142,14 @@ class SignalSenderTest : FreeSpec({ capture(dataMessageSlot), ) } returns mockk { - every { address } returns recipientPhone.asSignalServiceAddress() + every { address } returns contactPhoneNumber.asSignalServiceAddress() every { success } returns mockSuccess } val now = TimeUtil.nowInMillis() suspend fun sendHello(): SignalcSendResult = app.signalSender.send( sender = verifiedAccount, - recipient = recipientPhone.asSignalcAddress(), + recipient = contactPhoneNumber.asSignalcAddress(), body = "hello!", expiration = 5000, attachments = emptyList(), @@ -148,8 +160,8 @@ class SignalSenderTest : FreeSpec({ val result = sendHello() verify { anyConstructed<SignalServiceMessageSender>().sendMessage( - SignalServiceAddress(null, recipientPhone), - any(), // TODO: we actually pass something here! + SignalServiceAddress(null, contactPhoneNumber), + any(), signalDataMessage( body = "hello!", timestamp = now, @@ -164,7 +176,7 @@ class SignalSenderTest : FreeSpec({ every { TimeUtil.nowInMillis() } returns 1000L app.signalSender.send( verifiedAccount, - recipientPhone.asSignalcAddress(), + contactPhoneNumber.asSignalcAddress(), "hello!", DEFAULT_EXPIRY_TIME, emptyList() @@ -186,7 +198,7 @@ class SignalSenderTest : FreeSpec({ } app.signalSender.send( verifiedAccount, - recipientPhone.asSignalcAddress(), + contactPhoneNumber.asSignalcAddress(), "", DEFAULT_EXPIRY_TIME, emptyList() @@ -204,7 +216,7 @@ class SignalSenderTest : FreeSpec({ "when sealed sender tokens can be derived from a recipient's profile key" - { val mockkUnidentifiedAccessPair = mockk<UnidentifiedAccessPair>() coEvery { - app.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone) + app.accountManager.getUnidentifiedAccessPair(verifiedAccount.id, contactPhoneNumber) } returns mockkUnidentifiedAccessPair "sends a sealed sender message with access tokens derived from key" { @@ -222,7 +234,7 @@ class SignalSenderTest : FreeSpec({ "when sealed sender tokens cannot be derrived for a recipient (b/c missing profile key)" - { "when unsealed messages are allowed" - { coEvery { - app.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone) + app.accountManager.getUnidentifiedAccessPair(verifiedAccount.id, contactPhoneNumber) } returns null "sends an unsealed-sender message (with no access tokens)" { @@ -245,10 +257,10 @@ class SignalSenderTest : FreeSpec({ ) val app2 = Application(config2).run(testScope) coEvery { - app2.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone) + app2.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, contactPhoneNumber) } returns null - val recipientAddress = recipientPhone.asSignalcAddress() + val recipientAddress = contactPhoneNumber.asSignalcAddress() afterTest { app2.stop() @@ -297,7 +309,7 @@ class SignalSenderTest : FreeSpec({ app.signalSender.send( sender = verifiedAccount, - recipient = recipientPhone.asSignalcAddress(), + recipient = contactPhoneNumber.asSignalcAddress(), body = "hello!", expiration = 5000, attachments = listOf(sendAttachment), @@ -334,6 +346,46 @@ class SignalSenderTest : FreeSpec({ } } + "#sendProfileKey" - { + sendingSucceeds() + + "sends a profile key to a contact" { + app.signalSender.sendProfileKey(verifiedAccount, contactPhoneNumber.asSignalcAddress()) + verify { + anyConstructed<SignalServiceMessageSender>().sendMessage( + contactPhoneNumber.asSignalServiceAddress(), + any(), + signalDataMessage( + isProfileKeyUpdate = true, + profileKey = verifiedAccount.profileKeyBytes, + ) + ) + } + } + } + + "#sendReceipt" - { + val timestamp = nowInMillis() + every { + anyConstructed<SignalServiceMessageSender>().sendReceipt(any(), any(), any()) + } returns Unit + + "sends a READ receipt to a contact" { + app.signalSender.sendReceipt(verifiedAccount, contactPhoneNumber.asSignalcAddress(), timestamp) + verify { + anyConstructed<SignalServiceMessageSender>().sendReceipt( + contactPhoneNumber.asSignalServiceAddress(), + Optional.absent(), + signalReceiptMessage( + type = SignalServiceReceiptMessage.Type.READ, + timestamps = listOf(timestamp), + id = timestamp, + ) + ) + } + } + } + "#setExpiration" - { val recipientPhone = genPhoneNumber() every { @@ -352,7 +404,7 @@ class SignalSenderTest : FreeSpec({ verify { anyConstructed<SignalServiceMessageSender>().sendMessage( SignalServiceAddress(null, recipientPhone), - any(), // TODO: assert that we passed correct access pair here! + Optional.absent(), signalExpirationUpdate(60) ) } diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SocketReceiverTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SocketReceiverTest.kt index 081009c0bf7f7496f49a219656a92f5e84701284..1a4ac35a5c71896fe5de46ecf1a559abb13f46ea 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SocketReceiverTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SocketReceiverTest.kt @@ -314,8 +314,6 @@ class SocketReceiverTest : FreeSpec({ client.send(sendRequestJson) eventually(timeout) { coVerify { - // TODO(aguestuser|2021-02-04): we dont' actually want this. - // we want halting errors to be treated like SendResult statuses below! app.socketSender.send( requestHandlingError(request, error) ) diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/model/SocketResponseTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/model/SocketResponseTest.kt index 4cd4882c894ad2babc8a13e35900f6f319d92e91..5fb319643c24f486eb6380ec68f9d0e9029442a1 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/model/SocketResponseTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/model/SocketResponseTest.kt @@ -122,7 +122,6 @@ class SocketResponseTest : FreeSpec({ } "of Cleartext" - { - // TODO: modify to match signald val response = genCleartext() "encodes to JSON" { @@ -180,14 +179,14 @@ class SocketResponseTest : FreeSpec({ response.toJson() shouldBe """ |{ |"type":"decryption_error", - |"sender":{ - |"number":"${response.sender.number}", - |"uuid":"${response.sender.uuid}" - |}, |"recipient":{ |"number":"${response.recipient.number}", |"uuid":"${response.recipient.uuid}" |}, + |"sender":{ + |"number":"${response.sender?.number}", + |"uuid":"${response.sender?.uuid}" + |}, |"error":{ |"cause":"${response.error.javaClass.name}", |"message":"${response.error.message}" @@ -219,7 +218,7 @@ class SocketResponseTest : FreeSpec({ |"number":"${response.data.local_address.number}" |}, |"remote_address":{ - |"number":"${response.data.remote_address.number}" + |"number":"${response.data.remote_address?.number}" |}, |"fingerprint":"${response.data.fingerprint}" |} diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..1333f268121d75f8626d57fc05e1996799e092d2 --- /dev/null +++ b/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt @@ -0,0 +1,278 @@ +package info.signalboost.signalc.store + +import info.signalboost.signalc.util.KeyUtil.genRandomBytes +import com.zaxxer.hikari.HikariDataSource +import info.signalboost.signalc.Application +import info.signalboost.signalc.Config +import info.signalboost.signalc.testSupport.coroutines.CoroutineUtil.teardown +import info.signalboost.signalc.testSupport.dataGenerators.AccountGen.genVerifiedAccount +import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genPhoneNumber +import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genUuid +import info.signalboost.signalc.testSupport.dataGenerators.EnvelopeGen.genEnvelope +import info.signalboost.signalc.util.KeyUtil.genProfileKeyBytes +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.jetbrains.exposed.exceptions.ExposedSQLException +import java.lang.IllegalStateException +import java.util.* +import kotlin.io.path.ExperimentalPathApi +import kotlin.properties.Delegates +import kotlin.time.ExperimentalTime + +@ExperimentalPathApi +@ExperimentalTime +@ObsoleteCoroutinesApi +@ExperimentalCoroutinesApi +class ContactStoreTest: FreeSpec({ + runBlockingTest { + val testScope = this + val config = Config.mockAllExcept(ContactStore::class, HikariDataSource::class) + val app = Application(config).run(testScope) + + val account = genVerifiedAccount() + val accountId = account.id + val contactProfileKey = genRandomBytes(32) + val contactUpdatedProfileKey = genRandomBytes(32) + + afterSpec { + app.stop() + testScope.teardown() + } + + "Contact store" - { + afterTest { + app.contactStore.deleteAllFor(accountId) + } + + "#create" - { + val initCount = app.contactStore.count() + + "given all fields for a contact" - { + val id = app.contactStore.create(accountId, genPhoneNumber(), genUuid(), genProfileKeyBytes()) + + "creates a contact and returns its id" { + id shouldBeGreaterThan 0 + app.contactStore.count() shouldBeGreaterThan initCount + } + } + + "given a phone number but no other fields" - { + val id = app.contactStore.create(accountId, genPhoneNumber(), null) + + "creates a contact and returns its id" { + id shouldBeGreaterThan 0 + app.contactStore.count() shouldBeGreaterThan initCount + } + } + + "given a uuid but no other fields" - { + val id = app.contactStore.create(accountId, null, genUuid()) + + "creates a contact and returns its id" { + id shouldBeGreaterThan 0 + app.contactStore.count() shouldBeGreaterThan initCount + } + } + + "given no phone number or uuid" - { + "throws an illegal state exception" { + shouldThrow<IllegalStateException> { + app.contactStore.create(genPhoneNumber(), null, null) + } + } + } + + "given an invalid phone number" - { + "throws a sql exception" { + shouldThrow<ExposedSQLException> { + app.contactStore.create(genPhoneNumber(), "foo", null) + } + } + } + + "given an account and an envelope" - { + val envelope = genEnvelope() + val id = app.contactStore.create(account, envelope) + + "creates a contact and returns its id" { + id shouldBeGreaterThan 0 + app.contactStore.count() shouldBeGreaterThan initCount + app.contactStore.hasContact(accountId, envelope.sourceE164.get()) shouldBe true + app.contactStore.hasContact(accountId, envelope.sourceUuid.get()) shouldBe true + } + } + } + + "#hasContact" - { + val contactPhoneNumber = genPhoneNumber() + val contactUuid = genUuid() + + beforeTest { + app.contactStore.create(accountId, contactPhoneNumber, contactUuid) + } + + "true for the uuid of an existing contact" { + app.contactStore.hasContact(accountId, contactUuid.toString()) shouldBe true + } + + "false for the uuid of a non-existent contact" { + app.contactStore.hasContact(accountId, genUuid().toString()) shouldBe false + } + + "true for the phone number of an existing contact" { + app.contactStore.hasContact(accountId, contactPhoneNumber) shouldBe true + } + + "false for the phone number of a non-existent contact" - { + app.contactStore.hasContact(accountId, genPhoneNumber()) shouldBe false + } + + "false given a junk string" - { + app.contactStore.hasContact(accountId, "foo") shouldBe false + } + } + + "#resolveContactId" - { + var contactPhoneNumber by Delegates.notNull<String>() + var contactUuid by Delegates.notNull<UUID>() + var contactId by Delegates.notNull<Int>() + var initCount by Delegates.notNull<Long>() + + beforeTest { + contactPhoneNumber = genPhoneNumber() + contactUuid = genUuid() + contactId = app.contactStore.create(accountId, contactPhoneNumber, contactUuid) + initCount = app.contactStore.count() + } + + "given the uuid of an existing contact" - { + app.contactStore.hasContact(accountId, contactUuid.toString()) shouldBe true + + "returns the numeric id of that contact" { + app.contactStore.resolveContactId(accountId, contactUuid.toString()) shouldBe contactId + app.contactStore.count() shouldBe initCount + } + } + + "given the uuid of a non-existent contact" - { + val newUuid = genUuid() + app.contactStore.hasContact(accountId, newUuid.toString()) shouldBe false + + "creates a new contact with that uuid and returns its numeric id" { + app.contactStore.resolveContactId(accountId, newUuid.toString()) shouldBeGreaterThan contactId + app.contactStore.count() shouldBeGreaterThan initCount + app.contactStore.hasContact(accountId, newUuid.toString()) shouldBe true + } + } + + "given the phone number of an existing contact" - { + app.contactStore.hasContact(accountId, contactPhoneNumber) shouldBe true + + "returns the numeric id of that contact" { + app.contactStore.resolveContactId(accountId, contactPhoneNumber) shouldBe contactId + app.contactStore.count() shouldBe initCount + } + } + + "given the phone number for a non-existent contact" - { + val newPhoneNumber = genPhoneNumber() + + "creates a new phone number with that uuid and returns its numeric id" { + app.contactStore.resolveContactId(accountId, newPhoneNumber) shouldBeGreaterThan contactId + app.contactStore.count() shouldBeGreaterThan initCount + } + } + + "given a junk string for a phone number" - { + "throws SQL error" { + shouldThrow<ExposedSQLException> { + app.contactStore.resolveContactId(accountId, "foo") + } + } + } + } + + "#storeMissingIdentifier" - { + val contactPhoneNumber = genPhoneNumber() + val contactUuid = genUuid() + + "given a known phone number and a missing uuid" - { + app.contactStore.create(accountId, contactPhoneNumber, null) + app.contactStore.hasContact(accountId, contactUuid.toString()) shouldBe false + + "stores the uuid" { + app.contactStore.storeMissingIdentifier(accountId, contactPhoneNumber, contactUuid) + app.contactStore.hasContact(accountId, contactUuid.toString()) shouldBe true + } + } + + "given a known uuid and a missing phone number" - { + app.contactStore.create(accountId, null, contactUuid) + app.contactStore.hasContact(accountId, contactPhoneNumber) shouldBe false + + "stores the phone number" { + app.contactStore.storeMissingIdentifier(accountId, contactPhoneNumber, contactUuid) + app.contactStore.hasContact(accountId, contactPhoneNumber) shouldBe true + } + } + + "given an unknown uuid and an unknown phone number" - { + "throws IllegalStateException" { + shouldThrow<IllegalStateException> { + app.contactStore.storeMissingIdentifier(accountId, contactPhoneNumber, contactUuid) + } + } + } + } + + "#loadProfileKey" - { + val contactId = genPhoneNumber() + + beforeTest { + app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey) + } + + "returns profile key for a contact if it exists" { + app.contactStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey + } + + "returns null if no profile key exists for contact" { + app.contactStore.loadProfileKey(accountId, genPhoneNumber()) shouldBe null + } + } + + "#storeProfileKey" - { + val contactId = genPhoneNumber() + + "stores a new profile key for a new contact" { + val startingCount = app.contactStore.count() + app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey) + app.contactStore.count() shouldBe startingCount + 1 + app.contactStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey + } + + "overwrites the old profile key for an existing contact" { + val startingCount = app.contactStore.count() + app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey) + app.contactStore.storeProfileKey(accountId, contactId, contactUpdatedProfileKey) + app.contactStore.count() shouldBe startingCount + 1 + app.contactStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactUpdatedProfileKey + } + + "safely stores the same profile key twice" { + val startingCount = app.contactStore.count() + app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey) + app.contactStore.storeProfileKey(accountId, contactId, contactProfileKey) + app.contactStore.count() shouldBe startingCount + 1 + app.contactStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey + } + } + } + } +}) \ No newline at end of file diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/store/ProfileStoreTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/store/ProfileStoreTest.kt deleted file mode 100644 index 8abba4eebecfdd5c8a8998002d01e762a85eeba3..0000000000000000000000000000000000000000 --- a/signalc/src/test/kotlin/info/signalboost/signalc/store/ProfileStoreTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package info.signalboost.signalc.store - -import info.signalboost.signalc.util.KeyUtil.genRandomBytes -import org.signal.zkgroup.profiles.ProfileKey -import com.zaxxer.hikari.HikariDataSource -import info.signalboost.signalc.Application -import info.signalboost.signalc.Config -import info.signalboost.signalc.testSupport.coroutines.CoroutineUtil.teardown -import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genPhoneNumber -import io.kotest.core.spec.style.FreeSpec -import io.kotest.matchers.shouldBe -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.ObsoleteCoroutinesApi -import kotlinx.coroutines.test.runBlockingTest -import kotlin.io.path.ExperimentalPathApi -import kotlin.time.ExperimentalTime - -@ExperimentalPathApi -@ExperimentalTime -@ObsoleteCoroutinesApi -@ExperimentalCoroutinesApi -class ProfileStoreTest: FreeSpec({ - runBlockingTest { - val testScope = this - val config = Config.mockAllExcept(ProfileStore::class, HikariDataSource::class) - val app = Application(config).run(testScope) - - val accountId = genPhoneNumber() - val contactId = genPhoneNumber() - val contactProfileKey = genRandomBytes(32) - val contactUpdatedProfileKey = genRandomBytes(32) - - afterSpec { - app.stop() - testScope.teardown() - } - - "Profile store" - { - afterTest { - app.profileStore.deleteAllFor(accountId) - } - - "#loadProfileKey" - { - beforeTest { - app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey) - } - - "returns profile key for a contact if it exists" { - app.profileStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey - } - - "returns null if no profile key exists for contact" { - app.profileStore.loadProfileKey(accountId, genPhoneNumber()) shouldBe null - } - } - - "#storeProfileKey" - { - "stores a new profile key for a new contact" { - val startingCount = app.profileStore.count() - app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey) - app.profileStore.count() shouldBe startingCount + 1 - app.profileStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey - } - - "overwrites the old profile key for an existing contact" { - val startingCount = app.profileStore.count() - app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey) - app.profileStore.storeProfileKey(accountId, contactId, contactUpdatedProfileKey) - app.profileStore.count() shouldBe startingCount + 1 - app.profileStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactUpdatedProfileKey - } - - "safely stores the same profile key twice" { - val startingCount = app.profileStore.count() - app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey) - app.profileStore.storeProfileKey(accountId, contactId, contactProfileKey) - app.profileStore.count() shouldBe startingCount + 1 - app.profileStore.loadProfileKey(accountId, contactId)?.serialize() shouldBe contactProfileKey - } - } - } - } -}) \ No newline at end of file diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/store/ProtocolStoreTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/store/ProtocolStoreTest.kt index f8a1fc2a18e5c547d390b29d11c237f11d1f7dcd..fb93d08e0aca5c488c48dd1221aa7c24050f1d4f 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/store/ProtocolStoreTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/store/ProtocolStoreTest.kt @@ -32,13 +32,11 @@ import kotlin.time.ExperimentalTime class ProtocolStoreTest: FreeSpec({ runBlockingTest { val testScope = this - val accountId = genPhoneNumber() - val config = Config.mockAllExcept(ProtocolStore::class, HikariDataSource::class) + val config = Config.mockAllExcept(HikariDataSource::class, ProtocolStore::class, ContactStore::class) val app = Application(config).run(testScope) - val store = app.protocolStore.of(NewAccount(accountId)) - val senderOneAddress = SignalProtocolAddress(accountId, 42) - val senderTwoAddress = SignalProtocolAddress(genPhoneNumber(), 15) + val accountId = genPhoneNumber() + val accountAddress = SignalProtocolAddress(accountId, 42) // NOTE: An address is a combination of a username (uuid or e164-format phone number) and a device id. // This is how Signal represents that a user may have many devices and each device has its own session. val contact = object { @@ -53,22 +51,22 @@ class ProtocolStoreTest: FreeSpec({ ) } val contactAddress = contact.addresses[0] + val store = app.protocolStore.of(NewAccount(accountId)) + beforeSpec { + app.contactStore.create(accountId, contact.phoneNumber, null) + } afterSpec { app.stop() testScope.teardown() } - // TODO: all of the Identity tests (confusingly) assert on addresses for "sender"s - // (but the only identities we care about are for recipients) - "Identities store" - { val identityKey = KeyUtil.genIdentityKeyPair().publicKey val rotatedIdentityKey = KeyUtil.genIdentityKeyPair().publicKey afterTest { - store.removeIdentity(senderOneAddress) - store.removeIdentity(senderTwoAddress) + store.removeIdentity(accountAddress) store.removeIdentity(contact.addresses[0]) store.removeIdentity(contact.addresses[1]) store.removeOwnIdentity() @@ -107,12 +105,12 @@ class ProtocolStoreTest: FreeSpec({ } "trusts the first key it sees for an address" { - store.isTrustedIdentity(senderOneAddress, identityKey, Direction.RECEIVING) shouldBe true + store.isTrustedIdentity(accountAddress, identityKey, Direction.RECEIVING) shouldBe true } "trusts a key it has stored for an address" { - store.saveIdentity(senderOneAddress, identityKey) - store.isTrustedIdentity(senderOneAddress, identityKey, Direction.RECEIVING) shouldBe true + store.saveIdentity(accountAddress, identityKey) + store.isTrustedIdentity(accountAddress, identityKey, Direction.RECEIVING) shouldBe true } "trusts the same identity key for multiple devices" { @@ -342,7 +340,7 @@ class ProtocolStoreTest: FreeSpec({ afterTest { store.removeOwnIdentity() - store.removeIdentity(senderOneAddress) + store.removeIdentity(accountAddress) store.removePreKey(ids[0]) store.removeSignedPreKey(ids[0]) store.deleteAllSessions(contact.phoneNumber) @@ -357,12 +355,12 @@ class ProtocolStoreTest: FreeSpec({ "support separate and distinct identities" { store.identityKeyPair.serialize() shouldNotBe otherStore.identityKeyPair.serialize() - store.saveIdentity(senderOneAddress, KeyUtil.genIdentityKeyPair().publicKey) + store.saveIdentity(accountAddress, KeyUtil.genIdentityKeyPair().publicKey) otherStore.saveIdentity(otherAddress, KeyUtil.genIdentityKeyPair().publicKey) - store.getIdentity(senderOneAddress) shouldNotBe otherStore.getIdentity(otherAddress) + store.getIdentity(accountAddress) shouldNotBe otherStore.getIdentity(otherAddress) store.getIdentity(otherAddress) shouldBe null - otherStore.getIdentity(senderOneAddress) shouldBe null + otherStore.getIdentity(accountAddress) shouldBe null } "support separate and distinct prekeys" { diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/db/MigrationUtil.kt b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/db/MigrationUtil.kt index ca0393078f2ac8c6ddd7766fb60f14bf9848193d..6fc0f581b898fcd0a8aae32cd2b0f1f33fe23ace 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/db/MigrationUtil.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/db/MigrationUtil.kt @@ -1,8 +1,6 @@ package info.signalboost.signalc.testSupport.db -import info.signalboost.signalc.db.Identities -import info.signalboost.signalc.db.Profiles -import info.signalboost.signalc.db.SenderKeys +import info.signalboost.signalc.db.Contacts import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.transaction @@ -31,7 +29,7 @@ import org.jetbrains.exposed.sql.transactions.transaction fun main() { // change this assignment to - val table = Profiles + val table = Contacts val db = Database.connect( driver = "org.postgresql.Driver", url = "jdbc:postgresql://localhost:5432/signalc_scratch", diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt index 2431b04c2f51c46b2d92174ae117bf2bb94ddab8..fcdf41dd611c0f9353ef0c673c7cd2bc68bede81 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt @@ -4,6 +4,7 @@ import io.mockk.MockKMatcherScope import org.whispersystems.libsignal.util.guava.Optional import org.whispersystems.signalservice.api.messages.SignalServiceAttachment import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage object SignalMessageMatchers { @@ -12,14 +13,30 @@ object SignalMessageMatchers { timestamp: Long? = null, expiresInSeconds: Int? = null, attachments: Optional<List<SignalServiceAttachment>>? = null, + isProfileKeyUpdate: Boolean? = null, + profileKey: ByteArray? = null, ): SignalServiceDataMessage = match { // check for equality of each provided param. if param not provided, don't check it! body?.let{ _ -> it.body.or("") == body } ?: true && - timestamp?.let { _ -> it.timestamp == timestamp } ?: true && - expiresInSeconds?.let { _ -> it.expiresInSeconds == expiresInSeconds } ?: true && - attachments?.let { _ -> it.attachments == attachments } ?: true + timestamp?.let { _ -> it.timestamp == timestamp } ?: true && + expiresInSeconds?.let { _ -> it.expiresInSeconds == expiresInSeconds } ?: true && + attachments?.let { _ -> it.attachments == attachments } ?: true && + isProfileKeyUpdate?.let { _ -> it.isProfileKeyUpdate == isProfileKeyUpdate} ?: true && + profileKey?.let { _ -> it.profileKey.or(ByteArray(0)).contentEquals(profileKey) } ?: true } + fun MockKMatcherScope.signalReceiptMessage( + type: SignalServiceReceiptMessage.Type? = null, + timestamps: List<Long>? = null, + id: Long? = null, + ): SignalServiceReceiptMessage = match { + // check for equality of each provided param. if param not provided, don't check it! + type?.let{ _ -> it.type == type } ?: true && + timestamps?.let { _ -> it.timestamps == timestamps } ?: true && + id?.let { _ -> it.`when` == id } ?: true + } + + fun MockKMatcherScope.signalExpirationUpdate( expiresInSeconds: Int, ): SignalServiceDataMessage = match { diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SocketResponseMatchers.kt b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SocketResponseMatchers.kt index b0d277fdde334dc41188f0eda189c40ac2143039..a8bcab06579f0d9dc1b589472264fa607433f554 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SocketResponseMatchers.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SocketResponseMatchers.kt @@ -48,11 +48,11 @@ object SocketResponseMatchers { fun MockKMatcherScope.inboundIdentityFailure( sender: SignalcAddress, - recipient: SignalcAddress, + recipient: SignalcAddress?, fingerprint: String? ): SocketResponse.InboundIdentityFailure = match { - it.data.local_address.number == recipient.number && - it.data.remote_address.number == sender.number && + it.data.local_address.number == recipient?.number && + it.data.remote_address?.number == sender.number && it.data.fingerprint == fingerprint }