diff --git a/app/schemas/dev.threadline.data.db.ThreadlineDatabase/6.json b/app/schemas/dev.threadline.data.db.ThreadlineDatabase/6.json new file mode 100644 index 0000000..157eda8 --- /dev/null +++ b/app/schemas/dev.threadline.data.db.ThreadlineDatabase/6.json @@ -0,0 +1,533 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "d0d04c98a12fb3a0bc030c40336f99b8", + "entities": [ + { + "tableName": "known_hosts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`endpoint_key` TEXT NOT NULL, `hostname` TEXT NOT NULL, `port` INTEGER NOT NULL, `algorithm` TEXT NOT NULL, `encoded_key` BLOB NOT NULL, `first_seen_at_millis` INTEGER NOT NULL, `last_seen_at_millis` INTEGER NOT NULL, PRIMARY KEY(`endpoint_key`))", + "fields": [ + { + "fieldPath": "endpointKey", + "columnName": "endpoint_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "algorithm", + "columnName": "algorithm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encodedKey", + "columnName": "encoded_key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "firstSeenAtMillis", + "columnName": "first_seen_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenAtMillis", + "columnName": "last_seen_at_millis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "endpoint_key" + ] + } + }, + { + "tableName": "imported_private_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `format` TEXT NOT NULL, `key_type` TEXT NOT NULL, `public_key_fingerprint` TEXT NOT NULL, `ciphertext` BLOB NOT NULL, `initialization_vector` BLOB NOT NULL, `created_at_millis` INTEGER NOT NULL, `crypto_version` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "key_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKeyFingerprint", + "columnName": "public_key_fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "initializationVector", + "columnName": "initialization_vector", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "created_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cryptoVersion", + "columnName": "crypto_version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "host_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `hostname` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `preferred_identity_id` TEXT, `created_at_millis` INTEGER NOT NULL, `updated_at_millis` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "preferredIdentityId", + "columnName": "preferred_identity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtMillis", + "columnName": "created_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updated_at_millis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_host_profiles_preferred_identity_id", + "unique": false, + "columnNames": [ + "preferred_identity_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_host_profiles_preferred_identity_id` ON `${TABLE_NAME}` (`preferred_identity_id`)" + } + ] + }, + { + "tableName": "ssh_identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `label` TEXT NOT NULL, `username` TEXT NOT NULL, `authentication_method` TEXT NOT NULL, `imported_private_key_id` TEXT, `created_at_millis` INTEGER NOT NULL, `updated_at_millis` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`imported_private_key_id`) REFERENCES `imported_private_keys`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authenticationMethod", + "columnName": "authentication_method", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importedPrivateKeyId", + "columnName": "imported_private_key_id", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAtMillis", + "columnName": "created_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMillis", + "columnName": "updated_at_millis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ssh_identities_imported_private_key_id", + "unique": false, + "columnNames": [ + "imported_private_key_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ssh_identities_imported_private_key_id` ON `${TABLE_NAME}` (`imported_private_key_id`)" + } + ], + "foreignKeys": [ + { + "table": "imported_private_keys", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "imported_private_key_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "transcript_sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `display_name` TEXT NOT NULL, `hostname` TEXT NOT NULL, `port` INTEGER NOT NULL, `username` TEXT NOT NULL, `started_at_millis` INTEGER NOT NULL, `ended_at_millis` INTEGER NOT NULL, `turns_truncated` INTEGER NOT NULL, PRIMARY KEY(`session_id`))", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAtMillis", + "columnName": "started_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAtMillis", + "columnName": "ended_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "turnsTruncated", + "columnName": "turns_truncated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id" + ] + } + }, + { + "tableName": "transcript_turns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `command_id` TEXT NOT NULL, `turn_index` INTEGER NOT NULL, `command` TEXT NOT NULL, `execution_mode` TEXT NOT NULL, `command_truncated` INTEGER NOT NULL, `directory_at_start` TEXT, `submitted_at_millis` INTEGER NOT NULL, `started_at_millis` INTEGER, `completed_at_millis` INTEGER, `status` TEXT NOT NULL, `exit_status` INTEGER, `current_directory` TEXT, `output_truncated` INTEGER NOT NULL, `output_approximate` INTEGER NOT NULL, `output_byte_count` INTEGER NOT NULL, PRIMARY KEY(`session_id`, `command_id`), FOREIGN KEY(`session_id`) REFERENCES `transcript_sessions`(`session_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "command_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "turnIndex", + "columnName": "turn_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "command", + "columnName": "command", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "executionMode", + "columnName": "execution_mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandTruncated", + "columnName": "command_truncated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directoryAtStart", + "columnName": "directory_at_start", + "affinity": "TEXT" + }, + { + "fieldPath": "submittedAtMillis", + "columnName": "submitted_at_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAtMillis", + "columnName": "started_at_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAtMillis", + "columnName": "completed_at_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exitStatus", + "columnName": "exit_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "currentDirectory", + "columnName": "current_directory", + "affinity": "TEXT" + }, + { + "fieldPath": "outputTruncated", + "columnName": "output_truncated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputApproximate", + "columnName": "output_approximate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputByteCount", + "columnName": "output_byte_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "command_id" + ] + }, + "indices": [ + { + "name": "index_transcript_turns_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transcript_turns_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "transcript_sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "session_id" + ] + } + ] + }, + { + "tableName": "transcript_output_chunks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`session_id` TEXT NOT NULL, `command_id` TEXT NOT NULL, `chunk_index` INTEGER NOT NULL, `text` TEXT NOT NULL, PRIMARY KEY(`session_id`, `command_id`, `chunk_index`), FOREIGN KEY(`session_id`, `command_id`) REFERENCES `transcript_turns`(`session_id`, `command_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commandId", + "columnName": "command_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkIndex", + "columnName": "chunk_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "session_id", + "command_id", + "chunk_index" + ] + }, + "indices": [ + { + "name": "index_transcript_output_chunks_session_id_command_id", + "unique": false, + "columnNames": [ + "session_id", + "command_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transcript_output_chunks_session_id_command_id` ON `${TABLE_NAME}` (`session_id`, `command_id`)" + } + ], + "foreignKeys": [ + { + "table": "transcript_turns", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id", + "command_id" + ], + "referencedColumns": [ + "session_id", + "command_id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0d04c98a12fb3a0bc030c40336f99b8')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt index c1f26d9..194f4ec 100644 --- a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt +++ b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt @@ -36,6 +36,8 @@ import dev.threadline.core.transcript.CommandOutput import dev.threadline.core.transcript.CommandStatus import dev.threadline.core.transcript.CommandTurn import dev.threadline.data.host.KnownHostMetadata +import dev.threadline.data.identity.IdentityAuthenticationMethod +import dev.threadline.data.identity.SshIdentity import dev.threadline.data.key.ImportedPrivateKeyMetadata import dev.threadline.data.profile.SavedHostProfile import dev.threadline.data.transcript.SavedTranscriptSession @@ -68,7 +70,7 @@ class ConnectionFormRetentionTest { } compose.onNodeWithText("Connect to a server").assertExists() - compose.onNodeWithText("Passwords and private-key passphrases are never saved", substring = true) + compose.onNodeWithText("Passwords and private-key passphrases are entered", substring = true) .performScrollTo() .assertExists() compose.onNodeWithTag(ConnectionFormTags.HELP).performClick() @@ -744,12 +746,31 @@ class ConnectionFormRetentionTest { hostProfiles = profiles.value, selectedHostProfileId = selectedId.value, onSelectedHostProfileChange = { selectedId.value = it }, - onSaveHostProfile = { profile -> + onCreateDefaultSshIdentity = { profile, method, keyId -> + SshIdentity( + id = "default-identity", + label = profile.displayName, + username = profile.username, + authenticationMethod = when (method) { + AuthenticationMode.PASSWORD -> + IdentityAuthenticationMethod.PASSWORD + AuthenticationMode.PRIVATE_KEY -> if (keyId == null) { + IdentityAuthenticationMethod.UNCONFIGURED + } else { + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY + } + }, + importedPrivateKeyId = keyId, + createdAtMillis = 1, + updatedAtMillis = 1, + ) + }, + onSaveHostProfile = { profile, _ -> savedProfile = profile profiles.value = listOf(original) original }, - onUpdateHostProfile = { id, profile -> + onUpdateHostProfile = { id, profile, _ -> updatedProfile = id to profile }, onDeleteHostProfile = { id -> @@ -819,6 +840,157 @@ class ConnectionFormRetentionTest { .assertEditableTextEquals("") } + @Test + fun preferredIdentitySelectionSetsUsernameAndSavesOnlyTheProfilePreference() { + val originalIdentity = SshIdentity( + id = "legacy-identity", + label = "Legacy login", + username = "operator", + authenticationMethod = IdentityAuthenticationMethod.UNCONFIGURED, + importedPrivateKeyId = null, + createdAtMillis = 1, + updatedAtMillis = 1, + ) + val selectedIdentity = SshIdentity( + id = "deploy-identity", + label = "Deploy account", + username = "deploy", + authenticationMethod = IdentityAuthenticationMethod.PASSWORD, + importedPrivateKeyId = null, + createdAtMillis = 2, + updatedAtMillis = 2, + ) + val profile = SavedHostProfile( + id = "profile-id", + displayName = "Lab", + hostname = "lab.example", + port = 22, + username = originalIdentity.username, + createdAtMillis = 1, + updatedAtMillis = 1, + preferredIdentityId = originalIdentity.id, + ) + val selectedId = mutableStateOf(null) + val draft = mutableStateOf(ConnectionFormDraft.emptyDefaults()) + var updatedPreference: String? = null + + compose.setContent { + MaterialTheme { + HostForm( + draft = draft.value, + onDraftChange = { draft.value = it }, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + hostProfiles = listOf(profile), + selectedHostProfileId = selectedId.value, + onSelectedHostProfileChange = { selectedId.value = it }, + sshIdentities = listOf(originalIdentity, selectedIdentity), + onUpdateHostProfile = { _, _, identityId -> + updatedPreference = identityId + }, + onPrepared = { true }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + profile.id) + .performClick() + compose.onNodeWithTag(ConnectionFormTags.USERNAME) + .assertEditableTextEquals("operator") + compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY).performClick() + compose.onNodeWithText("Deploy account").performClick() + compose.onNodeWithTag(ConnectionFormTags.USERNAME) + .assertEditableTextEquals("deploy") + compose.onNodeWithTag(ConnectionFormTags.UPDATE_PROFILE) + .performScrollTo() + .performClick() + compose.waitForIdle() + + assertEquals(selectedIdentity.id, updatedPreference) + } + + @Test + fun savedProfileWithoutIdentityMustBeRepairedBeforeConnecting() { + val profile = SavedHostProfile( + id = "unlinked-profile", + displayName = "Lab", + hostname = "lab.example", + port = 22, + username = "operator", + createdAtMillis = 1, + updatedAtMillis = 1, + ) + var preparedCount = 0 + + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.fixtureDefaults(), + onDraftChange = {}, + sessionError = null, + initialTask = HomeTask.CONNECTION, + hostProfiles = listOf(profile), + selectedHostProfileId = profile.id, + notificationPermissionState = SessionNotificationPermissionState.GRANTED, + onPrepared = { + preparedCount += 1 + it.credential.clear() + true + }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.CONNECT) + .performScrollTo() + .performClick() + compose.onNodeWithText("Choose an SSH identity for this saved profile", substring = true) + .performScrollTo() + .assertIsDisplayed() + compose.runOnIdle { assertEquals(0, preparedCount) } + } + + @Test + fun deletingIdentityRequiresConfirmation() { + val identity = SshIdentity( + id = "identity-id", + label = "Work", + username = "operator", + authenticationMethod = IdentityAuthenticationMethod.PASSWORD, + importedPrivateKeyId = null, + createdAtMillis = 1, + updatedAtMillis = 1, + ) + var deletedId: String? = null + + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.emptyDefaults(), + onDraftChange = {}, + sessionError = null, + initialTask = HomeTask.OVERVIEW, + sshIdentities = listOf(identity), + onDeleteSshIdentity = { deletedId = it }, + onPrepared = { true }, + ) + } + } + + compose.onNodeWithTag(ConnectionFormTags.OPEN_SECURITY) + .performScrollTo() + .performClick() + compose.onNodeWithTag(IdentityTags.DELETE_PREFIX + identity.id) + .performScrollTo() + .performClick() + assertNull(deletedId) + compose.onNodeWithText("Delete SSH identity?").assertExists() + compose.onNodeWithTag(IdentityTags.CONFIRM_DELETE).performClick() + compose.waitForIdle() + + assertEquals(identity.id, deletedId) + } + @Test fun changedHostTrustCanOnlyBeForgottenAfterExplicitConfirmation() { val trustedHost = KnownHostMetadata( @@ -985,6 +1157,7 @@ class ConnectionFormRetentionTest { compose.onNodeWithTag(ConnectionFormTags.CONNECT).assertDoesNotExist() compose.onNodeWithTag(ConnectionFormTags.REQUEST_NOTIFICATION_PERMISSION) + .performScrollTo() .performClick() compose.runOnIdle { assertEquals(1, requestCount) diff --git a/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt b/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt index 2b5bc21..5b6d607 100644 --- a/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt @@ -188,6 +188,119 @@ class ThreadlineDatabaseMigrationTest { migrated.close() } + @Test + @Throws(IOException::class) + fun migrationFromFiveCreatesSeparateUnconfiguredIdentityForEachProfile() { + helper.createDatabase(DATABASE_NAME, 5).apply { + execSQL( + """ + INSERT INTO known_hosts ( + endpoint_key, hostname, port, algorithm, encoded_key, + first_seen_at_millis, last_seen_at_millis + ) VALUES ('fixture.test:22', 'fixture.test', 22, 'ssh-ed25519', X'010203', 10, 20) + """.trimIndent(), + ) + execSQL( + """ + INSERT INTO imported_private_keys ( + id, display_name, format, key_type, public_key_fingerprint, + ciphertext, initialization_vector, created_at_millis, crypto_version + ) VALUES ( + 'key-id', 'Fixture key', 'OpenSSH', 'ssh-ed25519', 'fixture-fingerprint', + X'010203', X'040506', 30, 1 + ) + """.trimIndent(), + ) + execSQL( + """ + INSERT INTO host_profiles ( + id, display_name, hostname, port, username, + created_at_millis, updated_at_millis + ) VALUES + ('profile-id', 'Fixture', 'fixture.test', 2222, 'threadline', 40, 50), + ('profile-id-2', 'Fixture 2', 'fixture2.test', 22, 'threadline', 60, 70) + """.trimIndent(), + ) + execSQL( + """ + INSERT INTO transcript_sessions ( + session_id, display_name, hostname, port, username, + started_at_millis, ended_at_millis, turns_truncated + ) VALUES ('session-id', 'Fixture', 'fixture.test', 22, 'threadline', 10, 20, 0) + """.trimIndent(), + ) + close() + } + + val migrated = helper.runMigrationsAndValidate( + DATABASE_NAME, + 6, + true, + ThreadlineDatabase.MIGRATION_5_6, + ) + + migrated.query( + """ + SELECT id, label, username, authentication_method, imported_private_key_id, + created_at_millis, updated_at_millis + FROM ssh_identities + WHERE id = 'legacy-profile-id' + """.trimIndent(), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("legacy-profile-id", cursor.getString(0)) + assertEquals("Fixture", cursor.getString(1)) + assertEquals("threadline", cursor.getString(2)) + assertEquals("UNCONFIGURED", cursor.getString(3)) + assertTrue(cursor.isNull(4)) + assertEquals(40L, cursor.getLong(5)) + assertEquals(50L, cursor.getLong(6)) + } + migrated.query( + "SELECT preferred_identity_id FROM host_profiles WHERE id = 'profile-id'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("legacy-profile-id", cursor.getString(0)) + } + migrated.query( + """ + SELECT id, username, authentication_method, imported_private_key_id + FROM ssh_identities WHERE id = 'legacy-profile-id-2' + """.trimIndent(), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("legacy-profile-id-2", cursor.getString(0)) + assertEquals("threadline", cursor.getString(1)) + assertEquals("UNCONFIGURED", cursor.getString(2)) + assertTrue(cursor.isNull(3)) + } + migrated.query( + "SELECT preferred_identity_id FROM host_profiles WHERE id = 'profile-id-2'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("legacy-profile-id-2", cursor.getString(0)) + } + listOf( + "known_hosts", + "imported_private_keys", + "transcript_sessions", + ).forEach { table -> + migrated.query("SELECT COUNT(*) FROM `$table`").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(1, cursor.getInt(0)) + } + } + migrated.query("SELECT COUNT(*) FROM host_profiles").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(2, cursor.getInt(0)) + } + migrated.query("SELECT COUNT(*) FROM ssh_identities").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(2, cursor.getInt(0)) + } + migrated.close() + } + private companion object { const val DATABASE_NAME = "threadline-migration-test" } diff --git a/app/src/androidTest/java/dev/threadline/data/identity/RoomSshIdentityStoreTest.kt b/app/src/androidTest/java/dev/threadline/data/identity/RoomSshIdentityStoreTest.kt new file mode 100644 index 0000000..866c179 --- /dev/null +++ b/app/src/androidTest/java/dev/threadline/data/identity/RoomSshIdentityStoreTest.kt @@ -0,0 +1,182 @@ +package dev.threadline.data.identity + +import android.content.Context +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.threadline.core.model.HostEndpoint +import dev.threadline.core.model.HostProfile +import dev.threadline.data.db.ImportedPrivateKeyEntity +import dev.threadline.data.db.ThreadlineDatabase +import dev.threadline.data.profile.RoomHostProfileStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomSshIdentityStoreTest { + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private lateinit var database: ThreadlineDatabase + + @Before + fun createDatabase() { + context.deleteDatabase(DATABASE_NAME) + database = Room.databaseBuilder( + context, + ThreadlineDatabase::class.java, + DATABASE_NAME, + ).addMigrations( + ThreadlineDatabase.MIGRATION_1_2, + ThreadlineDatabase.MIGRATION_2_3, + ThreadlineDatabase.MIGRATION_3_4, + ThreadlineDatabase.MIGRATION_4_5, + ThreadlineDatabase.MIGRATION_5_6, + ).build() + } + + @After + fun closeDatabase() { + database.close() + context.deleteDatabase(DATABASE_NAME) + } + + @Test + fun identityNormalizesUpdatesAndRemainsReusableAcrossProfiles() = runBlocking { + var now = 10L + var nextId = 0 + val store = RoomSshIdentityStore( + dao = database.sshIdentities(), + ioDispatcher = Dispatchers.Unconfined, + currentTimeMillis = { now }, + newId = { "identity-${++nextId}" }, + ) + val identity = store.save( + label = " Work ", + username = " operator ", + authenticationMethod = IdentityAuthenticationMethod.PASSWORD, + importedPrivateKeyId = null, + ) + val profiles = RoomHostProfileStore( + dao = database.hostProfiles(), + ioDispatcher = Dispatchers.Unconfined, + ) + val first = profiles.save(profile("One", "one.example", "old"), identity.id) + val second = profiles.save(profile("Two", "two.example", "old"), identity.id) + + now = 20L + store.update( + id = identity.id, + label = "Work account", + username = "deploy", + authenticationMethod = IdentityAuthenticationMethod.UNCONFIGURED, + importedPrivateKeyId = null, + ) + + val updated = store.identities.first().single() + assertEquals(identity.id, updated.id) + assertEquals("Work account", updated.label) + assertEquals("deploy", updated.username) + assertEquals(10L, updated.createdAtMillis) + assertEquals(20L, updated.updatedAtMillis) + assertEquals("deploy", profiles.profiles.first().first { it.id == first.id }.username) + assertEquals("deploy", profiles.profiles.first().first { it.id == second.id }.username) + } + + @Test + fun deletingIdentityUnlinksProfilesAndKeepsImportedKey() = runBlocking { + database.importedPrivateKeys().insert( + ImportedPrivateKeyEntity( + id = "key-id", + displayName = "Fixture key", + format = "OpenSSH", + keyType = "ssh-ed25519", + publicKeyFingerprint = "fixture-fingerprint", + ciphertext = byteArrayOf(1, 2, 3), + initializationVector = byteArrayOf(4, 5, 6), + createdAtMillis = 1, + cryptoVersion = 1, + ), + ) + val store = RoomSshIdentityStore( + dao = database.sshIdentities(), + ioDispatcher = Dispatchers.Unconfined, + newId = { "identity-id" }, + ) + val identity = store.save( + label = "Fixture", + username = "threadline", + authenticationMethod = IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY, + importedPrivateKeyId = "key-id", + ) + val profiles = RoomHostProfileStore( + dao = database.hostProfiles(), + ioDispatcher = Dispatchers.Unconfined, + ) + val profile = profiles.save(profile("Fixture", "fixture.test", "threadline"), identity.id) + + store.delete(identity.id) + + assertNull(database.sshIdentities().find(identity.id)) + assertNull(database.hostProfiles().find(profile.id)?.preferredIdentityId) + assertEquals("key-id", database.importedPrivateKeys().find("key-id")?.id) + assertEquals("threadline", profiles.profiles.first().single().username) + } + + @Test + fun deletingImportedKeyLeavesIdentityPresentAndRequiresKeyRepair() = runBlocking { + database.importedPrivateKeys().insert( + ImportedPrivateKeyEntity( + id = "key-id", + displayName = "Fixture key", + format = "OpenSSH", + keyType = "ssh-ed25519", + publicKeyFingerprint = "fixture-fingerprint", + ciphertext = byteArrayOf(1, 2, 3), + initializationVector = byteArrayOf(4, 5, 6), + createdAtMillis = 1, + cryptoVersion = 1, + ), + ) + val store = RoomSshIdentityStore( + dao = database.sshIdentities(), + ioDispatcher = Dispatchers.Unconfined, + newId = { "identity-id" }, + ) + store.save( + label = "Fixture", + username = "threadline", + authenticationMethod = IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY, + importedPrivateKeyId = "key-id", + ) + + database.importedPrivateKeys().delete("key-id") + + val identity = store.identities.first().single() + assertEquals(IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY, identity.authenticationMethod) + assertNull(identity.importedPrivateKeyId) + assertTrue(database.importedPrivateKeys().find("key-id") == null) + } + + private fun profile( + displayName: String, + hostname: String, + username: String, + ) = HostProfile( + displayName = displayName, + endpoint = HostEndpoint(hostname, 22), + username = username, + ) + + private companion object { + const val DATABASE_NAME = "threadline-ssh-identity-test" + } +} diff --git a/app/src/androidTest/java/dev/threadline/data/profile/RoomHostProfileStoreTest.kt b/app/src/androidTest/java/dev/threadline/data/profile/RoomHostProfileStoreTest.kt index 94ceb23..9f9dcaf 100644 --- a/app/src/androidTest/java/dev/threadline/data/profile/RoomHostProfileStoreTest.kt +++ b/app/src/androidTest/java/dev/threadline/data/profile/RoomHostProfileStoreTest.kt @@ -8,6 +8,7 @@ import dev.threadline.core.model.HostEndpoint import dev.threadline.core.model.HostProfile import dev.threadline.data.db.HostProfileDao import dev.threadline.data.db.HostProfileEntity +import dev.threadline.data.db.HostProfileRow import dev.threadline.data.db.ThreadlineDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -124,6 +125,8 @@ class RoomHostProfileStoreTest { ThreadlineDatabase.MIGRATION_1_2, ThreadlineDatabase.MIGRATION_2_3, ThreadlineDatabase.MIGRATION_3_4, + ThreadlineDatabase.MIGRATION_4_5, + ThreadlineDatabase.MIGRATION_5_6, ).build() private fun profile( @@ -138,7 +141,7 @@ class RoomHostProfileStoreTest { ) private class FailingHostProfileDao : HostProfileDao { - override fun observeAll(): Flow> = emptyFlow() + override fun observeAll(): Flow> = emptyFlow() override suspend fun find(id: String): HostProfileEntity? = null @@ -152,6 +155,7 @@ class RoomHostProfileStoreTest { hostname: String, port: Int, username: String, + preferredIdentityId: String?, updatedAtMillis: Long, ): Int = error("database-path") diff --git a/app/src/main/java/dev/threadline/IdentityManagement.kt b/app/src/main/java/dev/threadline/IdentityManagement.kt new file mode 100644 index 0000000..a30d88f --- /dev/null +++ b/app/src/main/java/dev/threadline/IdentityManagement.kt @@ -0,0 +1,429 @@ +package dev.threadline + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import dev.threadline.data.identity.IdentityAuthenticationMethod +import dev.threadline.data.identity.SshIdentity +import dev.threadline.data.key.ImportedPrivateKeyMetadata +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +internal data class SshIdentityDraft( + val id: String?, + val label: String, + val username: String, + val authenticationMethod: IdentityAuthenticationMethod, + val importedPrivateKeyId: String?, +) + +@Composable +internal fun PreferredIdentitySelector( + identities: List, + selectedIdentityId: String?, + enabled: Boolean, + onSelect: (SshIdentity?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedIdentity = identities.firstOrNull { it.id == selectedIdentityId } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Preferred identity", style = MaterialTheme.typography.labelLarge) + androidx.compose.foundation.layout.Box { + OutlinedButton( + onClick = { expanded = true }, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionFormTags.PREFERRED_IDENTITY), + ) { + Text( + selectedIdentity?.let { "${it.label} · ${it.username}" } + ?: "Choose later", + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text("No preferred identity") }, + onClick = { + expanded = false + onSelect(null) + }, + ) + identities.forEach { identity -> + DropdownMenuItem( + text = { + Column { + Text(identity.label) + Text( + "${identity.username} · ${identity.authenticationMethod.label()}", + style = MaterialTheme.typography.bodySmall, + ) + } + }, + onClick = { + expanded = false + onSelect(identity) + }, + ) + } + } + } + } +} + +@Composable +internal fun SshIdentityManagementContent( + identities: List, + importedPrivateKeys: List, + enabled: Boolean, + onSaveIdentity: suspend (SshIdentityDraft) -> Unit, + onDeleteIdentity: suspend (String) -> Unit, +) { + var editing by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + var managing by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var editorError by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "SSH identities", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.testTag(IdentityTags.HEADING), + ) + Text( + "An identity holds a username and authentication choice. Passwords and key " + + "passphrases are entered for each connection.", + style = MaterialTheme.typography.bodySmall, + ) + error?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + identities.forEach { identity -> + Card(modifier = Modifier.fillMaxWidth()) { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(12.dp), + ) { + Text(identity.label, style = MaterialTheme.typography.titleSmall) + Text( + "${identity.username} · ${identity.authenticationMethod.label()}" + + if ( + identity.authenticationMethod == + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY + ) { + importedPrivateKeys.firstOrNull { + it.id == identity.importedPrivateKeyId + }?.let { " · ${it.displayName}" } ?: " · Choose a key" + } else { + "" + }, + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + error = null + editorError = null + editing = identity.toDraft() + }, + enabled = enabled && !managing, + modifier = Modifier.testTag(IdentityTags.EDIT_PREFIX + identity.id), + ) { + Text("Edit") + } + TextButton( + onClick = { + error = null + deleting = identity + }, + enabled = enabled && !managing, + modifier = Modifier.testTag(IdentityTags.DELETE_PREFIX + identity.id), + ) { + Text("Delete") + } + } + } + } + } + if (identities.isEmpty()) Text("No saved SSH identities.") + OutlinedButton( + onClick = { + error = null + editorError = null + editing = SshIdentityDraft( + id = null, + label = "", + username = "", + authenticationMethod = IdentityAuthenticationMethod.UNCONFIGURED, + importedPrivateKeyId = null, + ) + }, + enabled = enabled && !managing, + modifier = Modifier + .fillMaxWidth() + .testTag(IdentityTags.ADD), + ) { + Text("Add identity") + } + } + + editing?.let { draft -> + IdentityEditorDialog( + initial = draft, + importedPrivateKeys = importedPrivateKeys, + enabled = enabled && !managing, + onDismiss = { if (!managing) editing = null }, + error = editorError, + onSave = { updated -> + managing = true + error = null + editorError = null + coroutineScope.launch { + try { + onSaveIdentity(updated) + editing = null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + editorError = failure.message ?: "The SSH identity could not be saved." + } finally { + managing = false + } + } + }, + ) + } + + deleting?.let { identity -> + AlertDialog( + onDismissRequest = { if (!managing) deleting = null }, + title = { Text("Delete SSH identity?") }, + text = { + Text( + "Host profiles using ${identity.label} will need another identity. " + + "The saved private key, if any, stays on this device.", + ) + }, + confirmButton = { + Button( + onClick = { + if (managing) return@Button + managing = true + error = null + coroutineScope.launch { + try { + onDeleteIdentity(identity.id) + deleting = null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + error = failure.message ?: "The SSH identity could not be deleted." + deleting = null + } finally { + managing = false + } + } + }, + enabled = enabled && !managing, + modifier = Modifier.testTag(IdentityTags.CONFIRM_DELETE), + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton( + onClick = { deleting = null }, + enabled = !managing, + ) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun IdentityEditorDialog( + initial: SshIdentityDraft, + importedPrivateKeys: List, + enabled: Boolean, + onDismiss: () -> Unit, + error: String?, + onSave: (SshIdentityDraft) -> Unit, +) { + var draft by remember(initial) { mutableStateOf(initial) } + var keyMenuExpanded by remember { mutableStateOf(false) } + val selectedKey = importedPrivateKeys.firstOrNull { + it.id == draft.importedPrivateKeyId + } + val canSave = draft.label.isNotBlank() && draft.username.isNotBlank() && + (draft.authenticationMethod != IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY || + draft.importedPrivateKeyId != null) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (draft.id == null) "Add SSH identity" else "Edit SSH identity") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + OutlinedTextField( + value = draft.label, + onValueChange = { draft = draft.copy(label = it) }, + label = { Text("Identity name") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(IdentityTags.LABEL), + ) + OutlinedTextField( + value = draft.username, + onValueChange = { draft = draft.copy(username = it) }, + label = { Text("Username") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(IdentityTags.USERNAME), + ) + Text("Authentication", style = MaterialTheme.typography.labelLarge) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + IdentityAuthenticationMethod.values().forEach { method -> + FilterChip( + selected = draft.authenticationMethod == method, + onClick = { + draft = draft.copy( + authenticationMethod = method, + importedPrivateKeyId = if ( + method == IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY + ) { + draft.importedPrivateKeyId + } else { + null + }, + ) + }, + label = { Text(method.label()) }, + modifier = Modifier + .fillMaxWidth() + .testTag(IdentityTags.method(method)), + ) + } + } + if ( + draft.authenticationMethod == + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY + ) { + androidx.compose.foundation.layout.Box { + OutlinedButton( + onClick = { keyMenuExpanded = true }, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .testTag(IdentityTags.PRIVATE_KEY), + ) { + Text(selectedKey?.displayName ?: "Choose saved private key") + } + DropdownMenu( + expanded = keyMenuExpanded, + onDismissRequest = { keyMenuExpanded = false }, + ) { + importedPrivateKeys.forEach { key -> + DropdownMenuItem( + text = { Text(key.displayName) }, + onClick = { + draft = draft.copy(importedPrivateKeyId = key.id) + keyMenuExpanded = false + }, + ) + } + } + } + if (importedPrivateKeys.isEmpty()) { + Text("Import a private key before choosing this method.") + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + Button( + onClick = { onSave(draft) }, + enabled = enabled && canSave, + modifier = Modifier.testTag(IdentityTags.SAVE), + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = enabled) { + Text("Cancel") + } + }, + ) +} + +internal object IdentityTags { + const val HEADING = "identity-heading" + const val ADD = "identity-add" + const val LABEL = "identity-label" + const val USERNAME = "identity-username" + const val PRIVATE_KEY = "identity-private-key" + const val SAVE = "identity-save" + const val CONFIRM_DELETE = "identity-confirm-delete" + const val UNCONFIGURED = "identity-auth-unconfigured" + const val PASSWORD = "identity-auth-password" + const val IMPORTED_KEY = "identity-auth-imported-key" + const val EDIT_PREFIX = "identity-edit-" + const val DELETE_PREFIX = "identity-delete-" + + fun method(method: IdentityAuthenticationMethod): String = when (method) { + IdentityAuthenticationMethod.UNCONFIGURED -> UNCONFIGURED + IdentityAuthenticationMethod.PASSWORD -> PASSWORD + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY -> IMPORTED_KEY + } +} + +private fun SshIdentity.toDraft() = SshIdentityDraft( + id = id, + label = label, + username = username, + authenticationMethod = authenticationMethod, + importedPrivateKeyId = importedPrivateKeyId, +) + +private fun IdentityAuthenticationMethod.label(): String = when (this) { + IdentityAuthenticationMethod.UNCONFIGURED -> "Choose when connecting" + IdentityAuthenticationMethod.PASSWORD -> "Password" + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY -> "Private key" +} diff --git a/app/src/main/java/dev/threadline/MainActivity.kt b/app/src/main/java/dev/threadline/MainActivity.kt index f287c4c..2f75896 100644 --- a/app/src/main/java/dev/threadline/MainActivity.kt +++ b/app/src/main/java/dev/threadline/MainActivity.kt @@ -91,6 +91,8 @@ import dev.threadline.core.diagnostics.DiagnosticReportInput import dev.threadline.core.diagnostics.diagnosticSessionSnapshot import dev.threadline.core.diagnostics.generateDiagnosticReport import dev.threadline.data.host.KnownHostMetadata +import dev.threadline.data.identity.IdentityAuthenticationMethod +import dev.threadline.data.identity.SshIdentity import dev.threadline.data.key.ImportedPrivateKeyMetadata import dev.threadline.data.profile.SavedHostProfile import dev.threadline.data.transcript.SavedTranscriptSession @@ -125,6 +127,12 @@ internal enum class AuthenticationMode { PRIVATE_KEY, } +private fun IdentityAuthenticationMethod.toAuthenticationMode(): AuthenticationMode? = when (this) { + IdentityAuthenticationMethod.UNCONFIGURED -> null + IdentityAuthenticationMethod.PASSWORD -> AuthenticationMode.PASSWORD + IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY -> AuthenticationMode.PRIVATE_KEY +} + internal enum class HomeTask { OVERVIEW, CONNECTION, @@ -236,6 +244,7 @@ internal object ConnectionFormTags { const val SAVE_PROFILE = "connection-save-profile" const val UPDATE_PROFILE = "connection-update-profile" const val USE_PROFILE_AS_NEW = "connection-use-profile-as-new" + const val PREFERRED_IDENTITY = "connection-preferred-identity" const val DELETE_PROFILE_PREFIX = "connection-delete-profile-" const val CONFIRM_DELETE_PROFILE = "connection-confirm-delete-profile" const val TRUSTED_HOST_PREFIX = "connection-trusted-host-" @@ -267,6 +276,8 @@ private fun ThreadlineApp() { val snapshot by manager.snapshot.collectAsStateWithLifecycle() val importedPrivateKeys by SessionRuntime.importedPrivateKeys.keys .collectAsStateWithLifecycle(initialValue = emptyList()) + val sshIdentities by SessionRuntime.sshIdentities.identities + .collectAsStateWithLifecycle(initialValue = emptyList()) val hostProfiles by SessionRuntime.hostProfiles.profiles .collectAsStateWithLifecycle(initialValue = emptyList()) val knownHosts by SessionRuntime.knownHosts.hosts @@ -402,8 +413,12 @@ private fun ThreadlineApp() { hostProfiles = hostProfiles, selectedHostProfileId = selectedHostProfileId, onSelectedHostProfileChange = { selectedHostProfileId = it }, - onSaveHostProfile = SessionRuntime.hostProfiles::save, - onUpdateHostProfile = SessionRuntime.hostProfiles::update, + onSaveHostProfile = { profile, identityId -> + SessionRuntime.hostProfiles.save(profile, identityId) + }, + onUpdateHostProfile = { id, profile, identityId -> + SessionRuntime.hostProfiles.update(id, profile, identityId) + }, onDeleteHostProfile = SessionRuntime.hostProfiles::delete, knownHosts = knownHosts, onDeleteKnownHost = SessionRuntime.knownHosts::delete, @@ -413,6 +428,42 @@ private fun ThreadlineApp() { onDeleteTranscript = SessionRuntime.transcriptHistory::delete, onClearTranscriptHistory = SessionRuntime.transcriptHistory::clearAll, importedPrivateKeys = importedPrivateKeys, + sshIdentities = sshIdentities, + onSaveSshIdentity = { identity -> + if (identity.id == null) { + SessionRuntime.sshIdentities.save( + label = identity.label, + username = identity.username, + authenticationMethod = identity.authenticationMethod, + importedPrivateKeyId = identity.importedPrivateKeyId, + ) + } else { + SessionRuntime.sshIdentities.update( + id = identity.id, + label = identity.label, + username = identity.username, + authenticationMethod = identity.authenticationMethod, + importedPrivateKeyId = identity.importedPrivateKeyId, + ) + } + }, + onCreateDefaultSshIdentity = { profile, mode, privateKeyId -> + val method = when { + mode == AuthenticationMode.PASSWORD -> + IdentityAuthenticationMethod.PASSWORD + privateKeyId != null -> IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY + else -> IdentityAuthenticationMethod.UNCONFIGURED + } + SessionRuntime.sshIdentities.save( + label = profile.displayName, + username = profile.username, + authenticationMethod = method, + importedPrivateKeyId = privateKeyId.takeIf { + method == IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY + }, + ) + }, + onDeleteSshIdentity = SessionRuntime.sshIdentities::delete, onSavePrivateKey = SessionRuntime.importedPrivateKeys::save, onLoadPrivateKey = SessionRuntime.importedPrivateKeys::credential, onRenamePrivateKey = SessionRuntime.importedPrivateKeys::rename, @@ -623,10 +674,10 @@ internal fun HostForm( hostProfiles: List = emptyList(), selectedHostProfileId: String? = null, onSelectedHostProfileChange: (String?) -> Unit = {}, - onSaveHostProfile: suspend (HostProfile) -> SavedHostProfile = { + onSaveHostProfile: suspend (HostProfile, String?) -> SavedHostProfile = { _, _ -> error("Host-profile storage is unavailable.") }, - onUpdateHostProfile: suspend (id: String, profile: HostProfile) -> Unit = { _, _ -> + onUpdateHostProfile: suspend (id: String, profile: HostProfile, String?) -> Unit = { _, _, _ -> error("Host-profile storage is unavailable.") }, onDeleteHostProfile: suspend (id: String) -> Unit = { @@ -648,6 +699,20 @@ internal fun HostForm( error("Transcript history is unavailable.") }, importedPrivateKeys: List = emptyList(), + sshIdentities: List = emptyList(), + onSaveSshIdentity: suspend (SshIdentityDraft) -> Unit = { + error("SSH identity storage is unavailable.") + }, + onCreateDefaultSshIdentity: suspend ( + profile: HostProfile, + authenticationMode: AuthenticationMode, + importedPrivateKeyId: String?, + ) -> SshIdentity = { _, _, _ -> + error("SSH identity storage is unavailable.") + }, + onDeleteSshIdentity: suspend (id: String) -> Unit = { + error("SSH identity storage is unavailable.") + }, onSavePrivateKey: suspend ( displayName: String, keyBytes: ByteArray, @@ -685,6 +750,7 @@ internal fun HostForm( var keyPassphrase by remember { mutableStateOf("") } var selectedKeyUri by rememberSaveable { mutableStateOf(null) } var selectedSavedKeyId by rememberSaveable { mutableStateOf(null) } + var selectedPreferredIdentityId by rememberSaveable { mutableStateOf(null) } var savePrivateKey by rememberSaveable { mutableStateOf(false) } var formError by remember { mutableStateOf(null) } var validationError by remember { mutableStateOf(null) } @@ -826,20 +892,28 @@ internal fun HostForm( onDisconnectActiveSession = onDisconnectActiveSession, onOpenProfile = { profile -> onSelectedHostProfileChange(profile.id) + selectedPreferredIdentityId = profile.preferredIdentityId + val identity = sshIdentities.firstOrNull { + it.id == profile.preferredIdentityId + } onDraftChange( draft.copy( displayName = profile.displayName, hostname = profile.hostname, port = profile.port.toString(), - username = profile.username, + username = identity?.username ?: profile.username, + authenticationMode = identity?.authenticationMethod + ?.toAuthenticationMode() ?: draft.authenticationMode, ), ) clearSessionCredentialInputs() + selectedSavedKeyId = identity?.importedPrivateKeyId formError = null savedTask = HomeTask.CONNECTION.name }, onNewConnection = { onSelectedHostProfileChange(null) + selectedPreferredIdentityId = null onDraftChange(ConnectionFormDraft.emptyDefaults()) clearSessionCredentialInputs() formError = null @@ -874,6 +948,7 @@ internal fun HostForm( SecurityManagementContent( knownHosts = knownHosts, importedPrivateKeys = importedPrivateKeys, + sshIdentities = sshIdentities, enabled = !isBusy, error = formError, onForgetHost = { @@ -889,6 +964,13 @@ internal fun HostForm( keyPendingDeletion = it formError = null }, + onSaveIdentity = onSaveSshIdentity, + onDeleteIdentity = { id -> + onDeleteSshIdentity(id) + if (selectedPreferredIdentityId == id) { + selectedPreferredIdentityId = null + } + }, ) return@Column } @@ -1015,6 +1097,7 @@ internal fun HostForm( value = draft.username, onValueChange = { clearValidationError(ConnectionValidationField.USERNAME) + selectedPreferredIdentityId = null onDraftChange(draft.copy(username = it)) }, label = { Text("Username") }, @@ -1030,6 +1113,26 @@ internal fun HostForm( ) } + PreferredIdentitySelector( + identities = sshIdentities, + selectedIdentityId = selectedPreferredIdentityId, + enabled = !isBusy, + onSelect = { identity -> + selectedPreferredIdentityId = identity?.id + if (identity != null) { + clearSessionCredentialInputs() + onDraftChange( + draft.copy( + username = identity.username, + authenticationMode = identity.authenticationMethod + .toAuthenticationMode() ?: draft.authenticationMode, + ), + ) + selectedSavedKeyId = identity.importedPrivateKeyId + } + }, + ) + Button( onClick = { if (isBusy) return@Button @@ -1044,12 +1147,29 @@ internal fun HostForm( coroutineScope.launch { try { val selectedId = selectedHostProfile?.id + val preferredIdentityId = selectedPreferredIdentityId + ?.takeIf { id -> sshIdentities.any { it.id == id } } + ?: onCreateDefaultSshIdentity( + profile, + draft.authenticationMode, + selectedSavedKeyId.takeIf { + draft.authenticationMode == AuthenticationMode.PRIVATE_KEY + }, + ).id if (selectedId == null) { - val saved = onSaveHostProfile(profile) + val saved = onSaveHostProfile( + profile, + preferredIdentityId, + ) onSelectedHostProfileChange(saved.id) } else { - onUpdateHostProfile(selectedId, profile) + onUpdateHostProfile( + selectedId, + profile, + preferredIdentityId, + ) } + selectedPreferredIdentityId = preferredIdentityId } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { @@ -1077,6 +1197,7 @@ internal fun HostForm( OutlinedButton( onClick = { onSelectedHostProfileChange(null) + selectedPreferredIdentityId = null clearSessionCredentialInputs() formError = null }, @@ -1101,8 +1222,8 @@ internal fun HostForm( } } Text( - "Profiles save the server address and username only. Passwords and private-key " + - "passphrases are never saved.", + "Profiles save the server address and preferred identity. Passwords and " + + "private-key passphrases are entered for each connection.", style = MaterialTheme.typography.bodySmall, ) @@ -1316,6 +1437,15 @@ internal fun HostForm( if (isBusy) return@Button formError = null connectionPreparationError = null + if ( + selectedHostProfile != null && + sshIdentities.none { it.id == selectedPreferredIdentityId } + ) { + connectionPreparationError = + "Choose an SSH identity for this saved profile, or use its details " + + "as a new connection." + return@Button + } val invalidField = draft.validationErrorOrNull() if (invalidField != null) { showValidationError(invalidField) @@ -1462,6 +1592,7 @@ internal fun HostForm( onDeleteHostProfile(profile.id) if (selectedHostProfileId == profile.id) { onSelectedHostProfileChange(null) + selectedPreferredIdentityId = null onDraftChange(ConnectionFormDraft.emptyDefaults()) clearSessionCredentialInputs() savedTask = HomeTask.OVERVIEW.name @@ -1738,7 +1869,7 @@ private fun HomeOverviewContent( style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() }, ) - if (hostProfiles.isEmpty()) { + if (hostProfiles.isEmpty()) { Text("No saved connections yet. Start with a new connection.") } else { Text("Choose a saved connection. Credentials are entered each time.") @@ -1753,7 +1884,11 @@ private fun HomeOverviewContent( Column(modifier = Modifier.fillMaxWidth()) { Text(profile.displayName) Text( - "${profile.username}@${profile.hostname}:${profile.port}", + if (profile.preferredIdentityId == null) { + "Choose identity · ${profile.hostname}:${profile.port}" + } else { + "${profile.username}@${profile.hostname}:${profile.port}" + }, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, ) @@ -1825,11 +1960,14 @@ private fun HomeTaskHeader( private fun SecurityManagementContent( knownHosts: List, importedPrivateKeys: List, + sshIdentities: List, enabled: Boolean, error: String?, onForgetHost: (KnownHostMetadata) -> Unit, onRenameKey: (ImportedPrivateKeyMetadata) -> Unit, onDeleteKey: (ImportedPrivateKeyMetadata) -> Unit, + onSaveIdentity: suspend (SshIdentityDraft) -> Unit, + onDeleteIdentity: suspend (String) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { error?.let { @@ -1936,6 +2074,15 @@ private fun SecurityManagementContent( } } } + + HorizontalDivider() + SshIdentityManagementContent( + identities = sshIdentities, + importedPrivateKeys = importedPrivateKeys, + enabled = enabled, + onSaveIdentity = onSaveIdentity, + onDeleteIdentity = onDeleteIdentity, + ) } } diff --git a/app/src/main/java/dev/threadline/SessionRuntime.kt b/app/src/main/java/dev/threadline/SessionRuntime.kt index 8c1e96d..f4525cb 100644 --- a/app/src/main/java/dev/threadline/SessionRuntime.kt +++ b/app/src/main/java/dev/threadline/SessionRuntime.kt @@ -8,6 +8,7 @@ import dev.threadline.core.ssh.HostKeyAlgorithmPolicy import dev.threadline.core.terminal.TerminalBridge import dev.threadline.data.db.ThreadlineDatabase import dev.threadline.data.host.RoomKnownHostStore +import dev.threadline.data.identity.RoomSshIdentityStore import dev.threadline.data.key.AndroidKeystorePrivateKeyCipher import dev.threadline.data.key.EncryptedImportedPrivateKeyStore import dev.threadline.data.profile.RoomHostProfileStore @@ -32,6 +33,9 @@ object SessionRuntime { internal lateinit var hostProfiles: RoomHostProfileStore private set + internal lateinit var sshIdentities: RoomSshIdentityStore + private set + internal lateinit var transcriptHistory: RoomTranscriptHistoryStore private set @@ -49,6 +53,7 @@ object SessionRuntime { cipher = AndroidKeystorePrivateKeyCipher(), ) val hostProfileStore = RoomHostProfileStore(threadlineDatabase.hostProfiles()) + val sshIdentityStore = RoomSshIdentityStore(threadlineDatabase.sshIdentities()) val transcriptHistoryStore = RoomTranscriptHistoryStore( threadlineDatabase.transcriptArchives(), ) @@ -74,6 +79,7 @@ object SessionRuntime { importedPrivateKeys = importedKeyStore knownHosts = knownHostStore hostProfiles = hostProfileStore + sshIdentities = sshIdentityStore transcriptHistory = transcriptHistoryStore terminal = bridge manager = sessionManager diff --git a/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt b/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt index 95f7059..2c7815c 100644 --- a/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt +++ b/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt @@ -139,7 +139,10 @@ internal interface ImportedPrivateKeyDao { suspend fun delete(id: String): Int } -@Entity(tableName = "host_profiles") +@Entity( + tableName = "host_profiles", + indices = [Index(value = ["preferred_identity_id"])], +) internal data class HostProfileEntity( @PrimaryKey val id: String, @@ -148,21 +151,128 @@ internal data class HostProfileEntity( val hostname: String, val port: Int, val username: String, + @ColumnInfo(name = "preferred_identity_id") + val preferredIdentityId: String?, + @ColumnInfo(name = "created_at_millis") + val createdAtMillis: Long, + @ColumnInfo(name = "updated_at_millis") + val updatedAtMillis: Long, +) + +@Entity( + tableName = "ssh_identities", + foreignKeys = [ + ForeignKey( + entity = ImportedPrivateKeyEntity::class, + parentColumns = ["id"], + childColumns = ["imported_private_key_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [Index(value = ["imported_private_key_id"])], +) +internal data class SshIdentityEntity( + @PrimaryKey + val id: String, + val label: String, + val username: String, + @ColumnInfo(name = "authentication_method") + val authenticationMethod: String, + @ColumnInfo(name = "imported_private_key_id") + val importedPrivateKeyId: String?, @ColumnInfo(name = "created_at_millis") val createdAtMillis: Long, @ColumnInfo(name = "updated_at_millis") val updatedAtMillis: Long, ) +internal data class HostProfileRow( + val id: String, + @ColumnInfo(name = "display_name") + val displayName: String, + val hostname: String, + val port: Int, + val username: String, + @ColumnInfo(name = "preferred_identity_id") + val preferredIdentityId: String?, + @ColumnInfo(name = "created_at_millis") + val createdAtMillis: Long, + @ColumnInfo(name = "updated_at_millis") + val updatedAtMillis: Long, +) + +@Dao +internal interface SshIdentityDao { + @Query( + """ + SELECT * FROM ssh_identities + ORDER BY label COLLATE NOCASE, username COLLATE NOCASE, id + """, + ) + fun observeAll(): Flow> + + @Query("SELECT * FROM ssh_identities WHERE id = :id") + suspend fun find(id: String): SshIdentityEntity? + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(entity: SshIdentityEntity) + + @Query( + """ + UPDATE ssh_identities + SET label = :label, + username = :username, + authentication_method = :authenticationMethod, + imported_private_key_id = :importedPrivateKeyId, + updated_at_millis = :updatedAtMillis + WHERE id = :id + """, + ) + suspend fun update( + id: String, + label: String, + username: String, + authenticationMethod: String, + importedPrivateKeyId: String?, + updatedAtMillis: Long, + ): Int + + @Query("UPDATE host_profiles SET preferred_identity_id = NULL WHERE preferred_identity_id = :id") + suspend fun unlinkProfiles(id: String) + + @Query("DELETE FROM ssh_identities WHERE id = :id") + suspend fun delete(id: String): Int + + @Transaction + suspend fun deleteAndUnlink(id: String): Int { + unlinkProfiles(id) + return delete(id) + } +} + @Dao internal interface HostProfileDao { @Query( """ - SELECT * FROM host_profiles - ORDER BY display_name COLLATE NOCASE, hostname COLLATE NOCASE, port, username, id + SELECT host_profiles.id, + host_profiles.display_name, + host_profiles.hostname, + host_profiles.port, + COALESCE(ssh_identities.username, host_profiles.username) AS username, + host_profiles.preferred_identity_id, + host_profiles.created_at_millis, + host_profiles.updated_at_millis + FROM host_profiles + LEFT JOIN ssh_identities + ON ssh_identities.id = host_profiles.preferred_identity_id + ORDER BY host_profiles.display_name COLLATE NOCASE, + host_profiles.hostname COLLATE NOCASE, + host_profiles.port, + username, + host_profiles.id """, ) - fun observeAll(): Flow> + fun observeAll(): Flow> @Query("SELECT * FROM host_profiles WHERE id = :id") suspend fun find(id: String): HostProfileEntity? @@ -177,6 +287,7 @@ internal interface HostProfileDao { hostname = :hostname, port = :port, username = :username, + preferred_identity_id = :preferredIdentityId, updated_at_millis = :updatedAtMillis WHERE id = :id """, @@ -187,6 +298,7 @@ internal interface HostProfileDao { hostname: String, port: Int, username: String, + preferredIdentityId: String?, updatedAtMillis: Long, ): Int @@ -396,17 +508,19 @@ internal interface TranscriptArchiveDao { KnownHostEntity::class, ImportedPrivateKeyEntity::class, HostProfileEntity::class, + SshIdentityEntity::class, TranscriptSessionEntity::class, TranscriptTurnEntity::class, TranscriptOutputChunkEntity::class, ], - version = 5, + version = 6, exportSchema = true, ) internal abstract class ThreadlineDatabase : RoomDatabase() { abstract fun knownHosts(): KnownHostDao abstract fun importedPrivateKeys(): ImportedPrivateKeyDao abstract fun hostProfiles(): HostProfileDao + abstract fun sshIdentities(): SshIdentityDao abstract fun transcriptArchives(): TranscriptArchiveDao companion object { @@ -422,6 +536,7 @@ internal abstract class ThreadlineDatabase : RoomDatabase() { MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, + MIGRATION_5_6, ).build() internal val MIGRATION_1_2 = object : Migration(1, 2) { @@ -539,5 +654,54 @@ internal abstract class ThreadlineDatabase : RoomDatabase() { ) } } + + internal val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `ssh_identities` ( + `id` TEXT NOT NULL, + `label` TEXT NOT NULL, + `username` TEXT NOT NULL, + `authentication_method` TEXT NOT NULL, + `imported_private_key_id` TEXT, + `created_at_millis` INTEGER NOT NULL, + `updated_at_millis` INTEGER NOT NULL, + PRIMARY KEY(`id`), + FOREIGN KEY(`imported_private_key_id`) + REFERENCES `imported_private_keys`(`id`) + ON UPDATE NO ACTION ON DELETE SET NULL + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS " + + "`index_ssh_identities_imported_private_key_id` " + + "ON `ssh_identities` (`imported_private_key_id`)", + ) + db.execSQL( + "ALTER TABLE `host_profiles` ADD COLUMN `preferred_identity_id` TEXT", + ) + db.execSQL( + """ + INSERT INTO `ssh_identities` ( + `id`, `label`, `username`, `authentication_method`, + `imported_private_key_id`, `created_at_millis`, `updated_at_millis` + ) + SELECT 'legacy-' || `id`, `display_name`, `username`, 'UNCONFIGURED', + NULL, `created_at_millis`, `updated_at_millis` + FROM `host_profiles` + """.trimIndent(), + ) + db.execSQL( + "UPDATE `host_profiles` SET `preferred_identity_id` = 'legacy-' || `id`", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS " + + "`index_host_profiles_preferred_identity_id` " + + "ON `host_profiles` (`preferred_identity_id`)", + ) + } + } } } diff --git a/app/src/main/java/dev/threadline/data/identity/RoomSshIdentityStore.kt b/app/src/main/java/dev/threadline/data/identity/RoomSshIdentityStore.kt new file mode 100644 index 0000000..51a79a8 --- /dev/null +++ b/app/src/main/java/dev/threadline/data/identity/RoomSshIdentityStore.kt @@ -0,0 +1,148 @@ +package dev.threadline.data.identity + +import dev.threadline.data.db.SshIdentityDao +import dev.threadline.data.db.SshIdentityEntity +import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +internal enum class IdentityAuthenticationMethod { + UNCONFIGURED, + PASSWORD, + IMPORTED_PRIVATE_KEY, +} + +internal data class SshIdentity( + val id: String, + val label: String, + val username: String, + val authenticationMethod: IdentityAuthenticationMethod, + val importedPrivateKeyId: String?, + val createdAtMillis: Long, + val updatedAtMillis: Long, +) + +internal class RoomSshIdentityStore( + private val dao: SshIdentityDao, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val currentTimeMillis: () -> Long = System::currentTimeMillis, + private val newId: () -> String = { UUID.randomUUID().toString() }, +) { + val identities: Flow> = dao.observeAll().map { entities -> + entities.map(SshIdentityEntity::toSshIdentity) + } + + suspend fun save( + label: String, + username: String, + authenticationMethod: IdentityAuthenticationMethod, + importedPrivateKeyId: String?, + ): SshIdentity = withContext(ioDispatcher) { + val normalized = normalize(label, username, authenticationMethod, importedPrivateKeyId) + val now = currentTimeMillis() + val entity = SshIdentityEntity( + id = newId(), + label = normalized.label, + username = normalized.username, + authenticationMethod = normalized.authenticationMethod.name, + importedPrivateKeyId = normalized.importedPrivateKeyId, + createdAtMillis = now, + updatedAtMillis = now, + ) + protectIdentityStorage("The SSH identity could not be saved.") { + dao.insert(entity) + } + entity.toSshIdentity() + } + + suspend fun update( + id: String, + label: String, + username: String, + authenticationMethod: IdentityAuthenticationMethod, + importedPrivateKeyId: String?, + ) = withContext(ioDispatcher) { + val normalized = normalize(label, username, authenticationMethod, importedPrivateKeyId) + val updated = protectIdentityStorage("The SSH identity could not be updated.") { + dao.update( + id = id, + label = normalized.label, + username = normalized.username, + authenticationMethod = normalized.authenticationMethod.name, + importedPrivateKeyId = normalized.importedPrivateKeyId, + updatedAtMillis = currentTimeMillis(), + ) + } + if (updated != 1) throw SshIdentityUnavailableException() + } + + suspend fun delete(id: String) = withContext(ioDispatcher) { + val deleted = protectIdentityStorage("The SSH identity could not be deleted.") { + dao.deleteAndUnlink(id) + } + if (deleted != 1) throw SshIdentityUnavailableException() + } +} + +internal class SshIdentityUnavailableException : Exception( + "The saved SSH identity is no longer available.", +) + +internal class SshIdentityStorageException( + message: String, + cause: Throwable, +) : Exception(message, cause) + +private data class NormalizedIdentity( + val label: String, + val username: String, + val authenticationMethod: IdentityAuthenticationMethod, + val importedPrivateKeyId: String?, +) + +private fun normalize( + label: String, + username: String, + authenticationMethod: IdentityAuthenticationMethod, + importedPrivateKeyId: String?, +): NormalizedIdentity { + val normalizedLabel = label.trim() + val normalizedUsername = username.trim() + require(normalizedLabel.isNotEmpty()) { "An identity label is required." } + require(normalizedUsername.isNotEmpty()) { "A username is required." } + require( + (authenticationMethod == IdentityAuthenticationMethod.IMPORTED_PRIVATE_KEY) == + (importedPrivateKeyId != null), + ) { "Choose a saved private key for this authentication method." } + return NormalizedIdentity( + label = normalizedLabel, + username = normalizedUsername, + authenticationMethod = authenticationMethod, + importedPrivateKeyId = importedPrivateKeyId, + ) +} + +private fun SshIdentityEntity.toSshIdentity() = SshIdentity( + id = id, + label = label, + username = username, + authenticationMethod = IdentityAuthenticationMethod.valueOf(authenticationMethod), + importedPrivateKeyId = importedPrivateKeyId, + createdAtMillis = createdAtMillis, + updatedAtMillis = updatedAtMillis, +) + +private suspend inline fun protectIdentityStorage( + message: String, + crossinline operation: suspend () -> T, +): T = try { + operation() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: Exception) { + throw SshIdentityStorageException(message, failure) +} diff --git a/app/src/main/java/dev/threadline/data/profile/RoomHostProfileStore.kt b/app/src/main/java/dev/threadline/data/profile/RoomHostProfileStore.kt index 6c0555b..bc27772 100644 --- a/app/src/main/java/dev/threadline/data/profile/RoomHostProfileStore.kt +++ b/app/src/main/java/dev/threadline/data/profile/RoomHostProfileStore.kt @@ -4,6 +4,7 @@ import dev.threadline.core.model.HostEndpoint import dev.threadline.core.model.HostProfile import dev.threadline.data.db.HostProfileDao import dev.threadline.data.db.HostProfileEntity +import dev.threadline.data.db.HostProfileRow import java.util.UUID import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -20,6 +21,7 @@ internal data class SavedHostProfile( val username: String, val createdAtMillis: Long, val updatedAtMillis: Long, + val preferredIdentityId: String? = null, ) { fun toHostProfile() = HostProfile( displayName = displayName, @@ -35,10 +37,13 @@ internal class RoomHostProfileStore( private val newId: () -> String = { UUID.randomUUID().toString() }, ) { val profiles: Flow> = dao.observeAll().map { entities -> - entities.map(HostProfileEntity::toSavedHostProfile) + entities.map(HostProfileRow::toSavedHostProfile) } - suspend fun save(profile: HostProfile): SavedHostProfile = withContext(ioDispatcher) { + suspend fun save( + profile: HostProfile, + preferredIdentityId: String? = null, + ): SavedHostProfile = withContext(ioDispatcher) { val normalized = profile.normalized() val now = currentTimeMillis() val entity = HostProfileEntity( @@ -47,6 +52,7 @@ internal class RoomHostProfileStore( hostname = normalized.endpoint.hostname, port = normalized.endpoint.port, username = normalized.username, + preferredIdentityId = preferredIdentityId, createdAtMillis = now, updatedAtMillis = now, ) @@ -59,6 +65,7 @@ internal class RoomHostProfileStore( suspend fun update( id: String, profile: HostProfile, + preferredIdentityId: String? = null, ) = withContext(ioDispatcher) { val normalized = profile.normalized() val updated = protectProfileStorage("The host profile could not be updated.") { @@ -68,6 +75,7 @@ internal class RoomHostProfileStore( hostname = normalized.endpoint.hostname, port = normalized.endpoint.port, username = normalized.username, + preferredIdentityId = preferredIdentityId, updatedAtMillis = currentTimeMillis(), ) } @@ -105,6 +113,18 @@ private fun HostProfileEntity.toSavedHostProfile() = SavedHostProfile( username = username, createdAtMillis = createdAtMillis, updatedAtMillis = updatedAtMillis, + preferredIdentityId = preferredIdentityId, +) + +private fun HostProfileRow.toSavedHostProfile() = SavedHostProfile( + id = id, + displayName = displayName, + hostname = hostname, + port = port, + username = username, + createdAtMillis = createdAtMillis, + updatedAtMillis = updatedAtMillis, + preferredIdentityId = preferredIdentityId, ) private suspend inline fun protectProfileStorage(