diff --git a/PROJECT_SPEC.md b/PROJECT_SPEC.md index 763166d..2144b85 100644 --- a/PROJECT_SPEC.md +++ b/PROJECT_SPEC.md @@ -777,13 +777,19 @@ If password persistence is added: - Encrypt it using Android Keystore-backed authenticated encryption. - Require an explicit “save password” choice. -- Support optional biometric/device-credential gating. +- Require strong-biometric or device-credential approval for every new connection that uses it. +- Keep unsupported devices session-only and provide manual entry after cancellation, key invalidation, + missing device lock, or device transfer. - Never expose the password through general app state or logs. -Implementation decision (2026-07-31): passwords and passphrases remain -session-only. Device-credential and biometric gating are deferred optional -hardening decisions, not Phase 4 exit blockers. Biometrics require a concrete -threat-model justification before reconsideration; see `docs/BACKLOG.md`. +Implementation decision (2026-09-26, issue #46): saving a password is an +explicit option on a reusable SSH identity. Android 11 or newer and a secure +device lock are required. Each identity uses a separate authentication-bound +Keystore key, and each save, replacement, or connection use requires device +approval. Passwords remain session-only on unsupported devices. Imported-key +passphrases and replies to privilege prompts remain session-only. Cancellation, +key invalidation, missing keys, and device transfer return to manual entry or +replacement; they never bypass device approval. ### 12.3 Known hosts diff --git a/app/schemas/dev.threadline.data.db.ThreadlineDatabase/7.json b/app/schemas/dev.threadline.data.db.ThreadlineDatabase/7.json new file mode 100644 index 0000000..73d1107 --- /dev/null +++ b/app/schemas/dev.threadline.data.db.ThreadlineDatabase/7.json @@ -0,0 +1,582 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "c275cfedc43847f8b869881beb013079", + "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": "saved_ssh_passwords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_id` TEXT NOT NULL, `ciphertext` BLOB NOT NULL, `initialization_vector` BLOB NOT NULL, `crypto_version` INTEGER NOT NULL, PRIMARY KEY(`identity_id`), FOREIGN KEY(`identity_id`) REFERENCES `ssh_identities`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "initializationVector", + "columnName": "initialization_vector", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cryptoVersion", + "columnName": "crypto_version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identity_id" + ] + }, + "foreignKeys": [ + { + "table": "ssh_identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identity_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, 'c275cfedc43847f8b869881beb013079')" + ] + } +} \ 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 194f4ec..bb36b71 100644 --- a/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt +++ b/app/src/androidTest/java/dev/threadline/ConnectionFormRetentionTest.kt @@ -70,7 +70,7 @@ class ConnectionFormRetentionTest { } compose.onNodeWithText("Connect to a server").assertExists() - compose.onNodeWithText("Passwords and private-key passphrases are entered", substring = true) + compose.onNodeWithText("Passwords are entered for each connection", substring = true) .performScrollTo() .assertExists() compose.onNodeWithTag(ConnectionFormTags.HELP).performClick() diff --git a/app/src/androidTest/java/dev/threadline/SavedPasswordConnectionTest.kt b/app/src/androidTest/java/dev/threadline/SavedPasswordConnectionTest.kt new file mode 100644 index 0000000..9fcaa6b --- /dev/null +++ b/app/src/androidTest/java/dev/threadline/SavedPasswordConnectionTest.kt @@ -0,0 +1,211 @@ +package dev.threadline + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import dev.threadline.core.model.SessionCredential +import dev.threadline.data.identity.IdentityAuthenticationMethod +import dev.threadline.data.identity.SshIdentity +import dev.threadline.data.key.SavedSshPasswordAuthenticationCancelledException +import dev.threadline.data.key.SavedSshPasswordKeyInvalidatedException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test + +class SavedPasswordConnectionTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun savedCredentialIsLoadedForEachConnectionAttempt() { + var authorizationCount = 0 + var preparedCredential: SessionCredential.Password? = null + + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.fixtureDefaults(), + onDraftChange = {}, + sessionError = null, + sshIdentities = listOf(savedIdentity()), + savedPasswordSupported = true, + onLoadSavedSshPassword = { identityId -> + assertEquals(IDENTITY_ID, identityId) + authorizationCount += 1 + val password = "saved-password".toCharArray() + try { + SessionCredential.Password.from(password) + } finally { + password.fill('\u0000') + } + }, + onPrepared = { request -> + preparedCredential = request.credential as SessionCredential.Password + true + }, + ) + } + } + + selectIdentity() + repeat(2) { attempt -> + compose.onNodeWithTag(ConnectionFormTags.CONNECT) + .performScrollTo() + .performClick() + compose.waitForIdle() + + compose.runOnIdle { + val credential = requireNotNull(preparedCredential) + assertEquals(attempt + 1, authorizationCount) + assertArrayEquals("saved-password".toCharArray(), credential.characters) + credential.clear() + assertArrayEquals(CharArray("saved-password".length), credential.characters) + } + } + } + + @Test + fun manualPasswordBypassesUnlockAndCanceledUnlockFallsBackToManualEntry() { + var authorizationCount = 0 + var preparedCredential: SessionCredential.Password? = null + var cancelFirstAttempt = true + + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.fixtureDefaults(), + onDraftChange = {}, + sessionError = null, + sshIdentities = listOf(savedIdentity()), + savedPasswordSupported = true, + onLoadSavedSshPassword = { + authorizationCount += 1 + if (cancelFirstAttempt) { + cancelFirstAttempt = false + throw SavedSshPasswordAuthenticationCancelledException() + } + error("A manual password must not try the saved password again.") + }, + onPrepared = { request -> + preparedCredential = request.credential as SessionCredential.Password + true + }, + ) + } + } + + selectIdentity() + compose.onNodeWithTag(ConnectionFormTags.CONNECT).performScrollTo().performClick() + compose.waitUntil { + compose.onAllNodesWithText( + "Device approval was canceled. Enter the password for this connection or try again.", + ).fetchSemanticsNodes().isNotEmpty() + } + compose.runOnIdle { assertNull(preparedCredential) } + + compose.onNodeWithTag(ConnectionFormTags.PASSWORD) + .performScrollTo() + .performTextInput("manual-password") + compose.onNodeWithText( + "Device approval was canceled. Enter the password for this connection or try again.", + ).assertDoesNotExist() + compose.onNodeWithTag(ConnectionFormTags.CONNECT).performScrollTo().performClick() + compose.waitForIdle() + + compose.runOnIdle { + val credential = requireNotNull(preparedCredential) + assertEquals(1, authorizationCount) + assertArrayEquals( + "manual-password".toCharArray(), + credential.characters, + ) + credential.clear() + assertArrayEquals( + CharArray("manual-password".length), + credential.characters, + ) + } + } + + @Test + fun invalidatedSavedPasswordCannotPrepareConnectionUntilManualEntry() { + var authorizationCount = 0 + var preparedCredential: SessionCredential.Password? = null + + compose.setContent { + MaterialTheme { + HostForm( + draft = ConnectionFormDraft.fixtureDefaults(), + onDraftChange = {}, + sessionError = null, + sshIdentities = listOf(savedIdentity()), + savedPasswordSupported = true, + onLoadSavedSshPassword = { + authorizationCount += 1 + throw SavedSshPasswordKeyInvalidatedException( + IllegalStateException("test invalidation"), + ) + }, + onPrepared = { request -> + preparedCredential = request.credential as SessionCredential.Password + true + }, + ) + } + } + + selectIdentity() + compose.onNodeWithTag(ConnectionFormTags.CONNECT).performScrollTo().performClick() + compose.waitUntil { + compose.onAllNodesWithText( + "Device security changed. Enter the password again to replace the saved copy.", + ).fetchSemanticsNodes().isNotEmpty() + } + compose.runOnIdle { + assertEquals(1, authorizationCount) + assertNull(preparedCredential) + } + + compose.onNodeWithTag(ConnectionFormTags.PASSWORD) + .performScrollTo() + .performTextInput("manual-password") + compose.onNodeWithTag(ConnectionFormTags.CONNECT).performScrollTo().performClick() + compose.waitForIdle() + + compose.runOnIdle { + val credential = requireNotNull(preparedCredential) + assertEquals(1, authorizationCount) + assertArrayEquals("manual-password".toCharArray(), credential.characters) + credential.clear() + } + } + + private fun selectIdentity() { + compose.onNodeWithTag(ConnectionFormTags.PREFERRED_IDENTITY) + .performScrollTo() + .performClick() + compose.onNodeWithText("Saved identity").performClick() + } + + private fun savedIdentity() = SshIdentity( + id = IDENTITY_ID, + label = "Saved identity", + username = "operator", + authenticationMethod = IdentityAuthenticationMethod.PASSWORD, + importedPrivateKeyId = null, + createdAtMillis = 1, + updatedAtMillis = 1, + hasSavedPassword = true, + ) + + private companion object { + const val IDENTITY_ID = "saved-identity" + } +} diff --git a/app/src/androidTest/java/dev/threadline/SavedPasswordManagementTest.kt b/app/src/androidTest/java/dev/threadline/SavedPasswordManagementTest.kt new file mode 100644 index 0000000..f202434 --- /dev/null +++ b/app/src/androidTest/java/dev/threadline/SavedPasswordManagementTest.kt @@ -0,0 +1,133 @@ +package dev.threadline + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import dev.threadline.data.identity.IdentityAuthenticationMethod +import dev.threadline.data.identity.SshIdentity +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test + +class SavedPasswordManagementTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun saveAndReplaceRequireExplicitActionAndRemovalIsConfirmationGated() { + val identities = mutableStateOf(listOf(identity())) + var savedIdentityId: String? = null + var savedPassword: CharArray? = null + var deletedIdentityId: String? = null + + compose.setContent { + MaterialTheme { + SshIdentityManagementContent( + identities = identities.value, + importedPrivateKeys = emptyList(), + savedPasswordSupported = true, + enabled = true, + onSaveIdentity = {}, + onSaveSavedPassword = { id, password -> + savedIdentityId = id + savedPassword = password.copyOf() + identities.value = listOf(identity(hasSavedPassword = true)) + }, + onDeleteSavedPassword = { id -> + deletedIdentityId = id + identities.value = listOf(identity(hasSavedPassword = false)) + }, + onDeleteIdentity = { error("Password removal must keep the identity.") }, + ) + } + } + + compose.onNodeWithTag(IdentityTags.EDIT_PREFIX + IDENTITY_ID).performClick() + compose.onNodeWithTag(IdentityTags.PASSWORD_VALUE).performTextInput("first-secret") + compose.onNodeWithTag(IdentityTags.SAVE_PASSWORD).performScrollTo().performClick() + compose.waitForIdle() + + compose.runOnIdle { + assertEquals(IDENTITY_ID, savedIdentityId) + assertArrayEquals("first-secret".toCharArray(), savedPassword) + savedPassword?.fill('\u0000') + } + compose.onNodeWithText("Saved password · device approval required for each connection") + .assertExists() + + compose.onNodeWithTag(IdentityTags.EDIT_PREFIX + IDENTITY_ID).performClick() + compose.onNodeWithTag(IdentityTags.PASSWORD_VALUE).performTextInput("replacement-secret") + compose.onNodeWithTag(IdentityTags.SAVE_PASSWORD).performScrollTo().performClick() + compose.waitForIdle() + compose.runOnIdle { + assertEquals(IDENTITY_ID, savedIdentityId) + assertArrayEquals("replacement-secret".toCharArray(), savedPassword) + savedPassword?.fill('\u0000') + } + + compose.onNodeWithTag(IdentityTags.DELETE_PASSWORD_PREFIX + IDENTITY_ID).performClick() + compose.onNodeWithText("Remove saved password?").assertIsDisplayed() + compose.runOnIdle { assertNull(deletedIdentityId) } + compose.onNodeWithText("Cancel").performClick() + compose.runOnIdle { assertNull(deletedIdentityId) } + + compose.onNodeWithTag(IdentityTags.DELETE_PASSWORD_PREFIX + IDENTITY_ID).performClick() + compose.onNodeWithTag(IdentityTags.CONFIRM_DELETE_PASSWORD).performClick() + compose.waitForIdle() + + compose.runOnIdle { assertEquals(IDENTITY_ID, deletedIdentityId) } + compose.onNodeWithTag(IdentityTags.EDIT_PREFIX + IDENTITY_ID).assertExists() + compose.onNodeWithText("Saved password · device approval required for each connection") + .assertDoesNotExist() + } + + @Test + fun olderDevicesKeepManualPasswordEntryAndCannotSaveOne() { + compose.setContent { + MaterialTheme { + SshIdentityManagementContent( + identities = listOf(identity()), + importedPrivateKeys = emptyList(), + savedPasswordSupported = false, + enabled = true, + onSaveIdentity = {}, + onSaveSavedPassword = { _, _ -> error("Must stay session-only.") }, + onDeleteSavedPassword = {}, + onDeleteIdentity = {}, + ) + } + } + + compose.onNodeWithTag(IdentityTags.EDIT_PREFIX + IDENTITY_ID).performClick() + compose.onNodeWithText( + "Saved passwords need Android 11 or newer and a secure screen lock.", + substring = true, + ) + .assertExists() + compose.onNodeWithTag(IdentityTags.PASSWORD_VALUE).assertDoesNotExist() + compose.onNodeWithTag(IdentityTags.SAVE_PASSWORD).assertDoesNotExist() + } + + private fun identity(hasSavedPassword: Boolean = false) = SshIdentity( + id = IDENTITY_ID, + label = "Fixture identity", + username = "operator", + authenticationMethod = IdentityAuthenticationMethod.PASSWORD, + importedPrivateKeyId = null, + createdAtMillis = 1, + updatedAtMillis = 1, + hasSavedPassword = hasSavedPassword, + ) + + private companion object { + const val IDENTITY_ID = "identity-id" + } +} 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 5b6d607..45f96d9 100644 --- a/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/dev/threadline/data/db/ThreadlineDatabaseMigrationTest.kt @@ -301,6 +301,44 @@ class ThreadlineDatabaseMigrationTest { migrated.close() } + @Test + @Throws(IOException::class) + fun migrationFromSixAddsSavedPasswordTableWithoutSavingLegacyCredentials() { + helper.createDatabase(DATABASE_NAME, 6).apply { + execSQL( + """ + INSERT INTO ssh_identities ( + id, label, username, authentication_method, imported_private_key_id, + created_at_millis, updated_at_millis + ) VALUES ( + 'identity-id', 'Fixture', 'operator', 'PASSWORD', NULL, 10, 20 + ) + """.trimIndent(), + ) + close() + } + + val migrated = helper.runMigrationsAndValidate( + DATABASE_NAME, + 7, + true, + ThreadlineDatabase.MIGRATION_6_7, + ) + + migrated.query( + "SELECT username, authentication_method FROM ssh_identities WHERE id = 'identity-id'", + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("operator", cursor.getString(0)) + assertEquals("PASSWORD", cursor.getString(1)) + } + migrated.query("SELECT COUNT(*) FROM saved_ssh_passwords").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0, 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 index 866c179..aef697f 100644 --- a/app/src/androidTest/java/dev/threadline/data/identity/RoomSshIdentityStoreTest.kt +++ b/app/src/androidTest/java/dev/threadline/data/identity/RoomSshIdentityStoreTest.kt @@ -40,6 +40,7 @@ class RoomSshIdentityStoreTest { ThreadlineDatabase.MIGRATION_3_4, ThreadlineDatabase.MIGRATION_4_5, ThreadlineDatabase.MIGRATION_5_6, + ThreadlineDatabase.MIGRATION_6_7, ).build() } diff --git a/app/src/androidTest/java/dev/threadline/data/key/AndroidKeystoreSshPasswordCipherTest.kt b/app/src/androidTest/java/dev/threadline/data/key/AndroidKeystoreSshPasswordCipherTest.kt new file mode 100644 index 0000000..0c75d68 --- /dev/null +++ b/app/src/androidTest/java/dev/threadline/data/key/AndroidKeystoreSshPasswordCipherTest.kt @@ -0,0 +1,98 @@ +package dev.threadline.data.key + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.util.UUID +import javax.crypto.Cipher +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AndroidKeystoreSshPasswordCipherTest { + private lateinit var identityId: String + private lateinit var cipher: AndroidKeystoreSshPasswordCipher + + @Before + fun createCipher() { + identityId = "test-${UUID.randomUUID()}" + cipher = AndroidKeystoreSshPasswordCipher() + } + + @After + fun removeKey() = runBlocking { + cipher.deleteKey(identityId) + } + + @Test + fun keystoreRejectsCipherThatHasNotBeenAuthorized() = runBlocking { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + assumeTrue( + supportsSavedSshPasswords( + InstrumentationRegistry.getInstrumentation().targetContext, + ), + ) + + var authorizationRequested = false + assertThrows(SavedSshPasswordProtectionException::class.java) { + runBlocking { + cipher.encrypt(identityId, "fixture-password".toCharArray()) { operation -> + authorizationRequested = true + operation + } + } + } + assertTrue(authorizationRequested) + Unit + } + + @Test + fun deletingOneIdentityKeyDoesNotRemoveAnotherAndMissingKeysFailClosed() = runBlocking { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + assumeTrue( + supportsSavedSshPasswords( + InstrumentationRegistry.getInstrumentation().targetContext, + ), + ) + val otherIdentity = "other-${UUID.randomUUID()}" + try { + listOf(identityId, otherIdentity).forEach { id -> + assertThrows(SavedSshPasswordProtectionException::class.java) { + runBlocking { + cipher.encrypt(id, "fixture-password".toCharArray()) { operation -> + operation + } + } + } + } + + cipher.deleteKey(identityId) + val encrypted = EncryptedSshPassword( + ciphertext = ByteArray(16), + initializationVector = ByteArray(12), + ) + assertThrows(SavedSshPasswordUnavailableException::class.java) { + runBlocking { cipher.decrypt(identityId, encrypted) { it } } + } + var authorizationRequested = false + assertThrows(SavedSshPasswordProtectionException::class.java) { + runBlocking { + cipher.decrypt(otherIdentity, encrypted) { + authorizationRequested = true + it + } + } + } + assertTrue(authorizationRequested) + } finally { + cipher.deleteKey(otherIdentity) + } + Unit + } +} diff --git a/app/src/androidTest/java/dev/threadline/data/key/EncryptedSshPasswordStoreTest.kt b/app/src/androidTest/java/dev/threadline/data/key/EncryptedSshPasswordStoreTest.kt new file mode 100644 index 0000000..5771aff --- /dev/null +++ b/app/src/androidTest/java/dev/threadline/data/key/EncryptedSshPasswordStoreTest.kt @@ -0,0 +1,166 @@ +package dev.threadline.data.key + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dev.threadline.data.db.HostProfileEntity +import dev.threadline.data.db.KnownHostEntity +import dev.threadline.data.db.SshIdentityEntity +import dev.threadline.data.db.ThreadlineDatabase +import javax.crypto.Cipher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EncryptedSshPasswordStoreTest { + private lateinit var database: ThreadlineDatabase + private lateinit var cipher: TestSshPasswordCipher + private lateinit var store: EncryptedSshPasswordStore + + @Before + fun createStore() { + database = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + ThreadlineDatabase::class.java, + ).allowMainThreadQueries().build() + runBlocking { + database.sshIdentities().insert( + SshIdentityEntity( + id = IDENTITY_ID, + label = "Fixture", + username = "operator", + authenticationMethod = "PASSWORD", + importedPrivateKeyId = null, + createdAtMillis = 1, + updatedAtMillis = 1, + ), + ) + } + cipher = TestSshPasswordCipher() + store = EncryptedSshPasswordStore( + dao = database.savedSshPasswords(), + cipher = cipher, + ioDispatcher = Dispatchers.Unconfined, + ) + } + + @After + fun closeDatabase() { + database.close() + } + + @Test + fun saveReplaceAndDeleteKeepOnlyCiphertextAndLeaveIdentity() = runBlocking { + database.hostProfiles().insert( + HostProfileEntity( + id = "profile-id", + displayName = "Fixture", + hostname = "example.test", + port = 22, + username = "operator", + preferredIdentityId = IDENTITY_ID, + createdAtMillis = 1, + updatedAtMillis = 1, + ), + ) + database.knownHosts().insert( + KnownHostEntity( + endpointKey = "example.test:22", + hostname = "example.test", + port = 22, + algorithm = "ssh-ed25519", + encodedKey = byteArrayOf(1, 2, 3), + firstSeenAtMillis = 1, + lastSeenAtMillis = 1, + ), + ) + val first = "old-password".toCharArray() + store.save(IDENTITY_ID, first) { it } + assertArrayEquals(CharArray(first.size), first) + val stored = requireNotNull(database.savedSshPasswords().find(IDENTITY_ID)) + assertFalse(stored.ciphertext.contentEquals("old-password".encodeToByteArray())) + + val replacement = "new-password".toCharArray() + store.save(IDENTITY_ID, replacement) { it } + assertArrayEquals(CharArray(replacement.size), replacement) + val credential = store.credential(IDENTITY_ID) { it } + assertArrayEquals("new-password".toCharArray(), credential.characters) + credential.clear() + assertArrayEquals(CharArray("new-password".length), credential.characters) + + store.delete(IDENTITY_ID) + + assertNull(database.savedSshPasswords().find(IDENTITY_ID)) + assertEquals(IDENTITY_ID, database.sshIdentities().find(IDENTITY_ID)?.id) + assertEquals("profile-id", database.hostProfiles().find("profile-id")?.id) + assertEquals("example.test", database.knownHosts().find("example.test:22")?.hostname) + assertEquals(listOf(IDENTITY_ID), cipher.deletedKeys) + } + + @Test + fun cancelledApprovalDoesNotPersistAndClearsInput() = runBlocking { + val input = "fixture-password".toCharArray() + + assertThrows(SavedSshPasswordAuthenticationCancelledException::class.java) { + runBlocking { + store.save(IDENTITY_ID, input) { + throw SavedSshPasswordAuthenticationCancelledException() + } + } + } + + assertArrayEquals(CharArray(input.size), input) + assertNull(database.savedSshPasswords().find(IDENTITY_ID)) + } + + private class TestSshPasswordCipher : SshPasswordCipher { + val deletedKeys = mutableListOf() + + override suspend fun encrypt( + identityId: String, + password: CharArray, + authorize: suspend (Cipher) -> Cipher, + ): EncryptedSshPassword { + authorize(Cipher.getInstance("AES/GCM/NoPadding")) + val bytes = ByteArray(password.size) { password[it].code.toByte() } + val ciphertext = bytes.map { (it.toInt() xor MASK).toByte() }.toByteArray() + bytes.fill(0) + return EncryptedSshPassword( + ciphertext = ciphertext, + initializationVector = ByteArray(12) { 7 }, + ) + } + + override suspend fun decrypt( + identityId: String, + encrypted: EncryptedSshPassword, + authorize: suspend (Cipher) -> Cipher, + ): CharArray { + authorize(Cipher.getInstance("AES/GCM/NoPadding")) + return CharArray(encrypted.ciphertext.size) { index -> + (encrypted.ciphertext[index].toInt() xor MASK).toChar() + } + } + + override suspend fun deleteKey(identityId: String) { + deletedKeys += identityId + } + + private companion object { + const val MASK = 0x5a + } + } + + private companion object { + const val IDENTITY_ID = "identity-id" + } +} 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 9f9dcaf..ebc6139 100644 --- a/app/src/androidTest/java/dev/threadline/data/profile/RoomHostProfileStoreTest.kt +++ b/app/src/androidTest/java/dev/threadline/data/profile/RoomHostProfileStoreTest.kt @@ -127,6 +127,7 @@ class RoomHostProfileStoreTest { ThreadlineDatabase.MIGRATION_3_4, ThreadlineDatabase.MIGRATION_4_5, ThreadlineDatabase.MIGRATION_5_6, + ThreadlineDatabase.MIGRATION_6_7, ).build() private fun profile( diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 196a76a..6f26867 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + , importedPrivateKeys: List, + savedPasswordSupported: Boolean, enabled: Boolean, onSaveIdentity: suspend (SshIdentityDraft) -> Unit, + onSaveSavedPassword: suspend (String, CharArray) -> Unit, + onDeleteSavedPassword: suspend (String) -> Unit, onDeleteIdentity: suspend (String) -> Unit, ) { var editing by remember { mutableStateOf(null) } var deleting by remember { mutableStateOf(null) } + var deletingPassword by remember { mutableStateOf(null) } var managing by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var editorError by remember { mutableStateOf(null) } @@ -121,8 +130,8 @@ internal fun SshIdentityManagementContent( modifier = Modifier.testTag(IdentityTags.HEADING), ) Text( - "An identity holds a username and authentication choice. Passwords and key " + - "passphrases are entered for each connection.", + "An identity holds a username and authentication choice. Saved passwords are " + + "optional and require device approval each time. Key passphrases stay session-only.", style = MaterialTheme.typography.bodySmall, ) error?.let { @@ -149,6 +158,12 @@ internal fun SshIdentityManagementContent( }, style = MaterialTheme.typography.bodySmall, ) + if (identity.hasSavedPassword) { + Text( + "Saved password · device approval required for each connection", + style = MaterialTheme.typography.bodySmall, + ) + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TextButton( onClick = { @@ -171,6 +186,20 @@ internal fun SshIdentityManagementContent( ) { Text("Delete") } + if (identity.hasSavedPassword) { + TextButton( + onClick = { + error = null + deletingPassword = identity + }, + enabled = enabled && !managing, + modifier = Modifier.testTag( + IdentityTags.DELETE_PASSWORD_PREFIX + identity.id, + ), + ) { + Text("Remove password") + } + } } } } @@ -202,6 +231,7 @@ internal fun SshIdentityManagementContent( initial = draft, importedPrivateKeys = importedPrivateKeys, enabled = enabled && !managing, + savedPasswordSupported = savedPasswordSupported, onDismiss = { if (!managing) editing = null }, error = editorError, onSave = { updated -> @@ -221,6 +251,74 @@ internal fun SshIdentityManagementContent( } } }, + onSavePassword = { updated, password -> + val identityId = requireNotNull(updated.id) + managing = true + editorError = null + coroutineScope.launch { + try { + onSaveIdentity(updated) + onSaveSavedPassword(identityId, password) + editing = null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + editorError = failure.message + ?: "The saved SSH password could not be saved." + } finally { + password.fill('\u0000') + managing = false + } + } + }, + ) + } + + deletingPassword?.let { identity -> + AlertDialog( + onDismissRequest = { if (!managing) deletingPassword = null }, + title = { Text("Remove saved password?") }, + text = { + Text( + "The encrypted password and its device key will be removed. " + + "${identity.label}, its host profiles, and trusted servers will stay.", + ) + }, + confirmButton = { + Button( + onClick = { + if (managing) return@Button + managing = true + error = null + coroutineScope.launch { + try { + onDeleteSavedPassword(identity.id) + deletingPassword = null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + error = failure.message + ?: "The saved SSH password could not be removed." + deletingPassword = null + } finally { + managing = false + } + } + }, + enabled = enabled && !managing, + modifier = Modifier.testTag(IdentityTags.CONFIRM_DELETE_PASSWORD), + ) { + Text("Remove password") + } + }, + dismissButton = { + TextButton( + onClick = { deletingPassword = null }, + enabled = !managing, + ) { + Text("Cancel") + } + }, ) } @@ -231,7 +329,12 @@ internal fun SshIdentityManagementContent( text = { Text( "Host profiles using ${identity.label} will need another identity. " + - "The saved private key, if any, stays on this device.", + "The saved private key, if any, stays on this device." + + if (identity.hasSavedPassword) { + " Its saved password and device key will also be removed." + } else { + "" + }, ) }, confirmButton = { @@ -277,11 +380,14 @@ private fun IdentityEditorDialog( initial: SshIdentityDraft, importedPrivateKeys: List, enabled: Boolean, + savedPasswordSupported: Boolean, onDismiss: () -> Unit, error: String?, onSave: (SshIdentityDraft) -> Unit, + onSavePassword: (SshIdentityDraft, CharArray) -> Unit, ) { var draft by remember(initial) { mutableStateOf(initial) } + var passwordState by remember(initial) { mutableStateOf(TextFieldState()) } var keyMenuExpanded by remember { mutableStateOf(false) } val selectedKey = importedPrivateKeys.firstOrNull { it.id == draft.importedPrivateKeyId @@ -373,6 +479,55 @@ private fun IdentityEditorDialog( Text("Import a private key before choosing this method.") } } + if (draft.authenticationMethod == IdentityAuthenticationMethod.PASSWORD) { + when { + draft.id == null -> Text( + "Save the identity first, then edit it to add an optional saved password.", + style = MaterialTheme.typography.bodySmall, + ) + + !savedPasswordSupported -> Text( + "Saved passwords need Android 11 or newer and a secure screen lock. " + + "You can still enter a password for each connection.", + style = MaterialTheme.typography.bodySmall, + ) + + else -> { + OutlinedSecureTextField( + state = passwordState, + label = { Text("${if (draft.hasSavedPassword) "Replace" else "Save"} password") }, + placeholder = { + if (draft.hasSavedPassword) { + Text("Leave blank to keep the saved password") + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + ), + modifier = Modifier + .fillMaxWidth() + .testTag(IdentityTags.PASSWORD_VALUE), + ) + TextButton( + onClick = { + val password = CharArray(passwordState.text.length) { + passwordState.text[it] + } + passwordState = TextFieldState() + onSavePassword(draft, password) + }, + enabled = enabled && passwordState.text.isNotEmpty(), + modifier = Modifier.testTag(IdentityTags.SAVE_PASSWORD), + ) { + Text(if (draft.hasSavedPassword) "Replace saved password" else "Save password") + } + Text( + "Device approval is required to save and for each connection that uses it.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } error?.let { Text(it, color = MaterialTheme.colorScheme.error) } } }, @@ -401,6 +556,10 @@ internal object IdentityTags { const val PRIVATE_KEY = "identity-private-key" const val SAVE = "identity-save" const val CONFIRM_DELETE = "identity-confirm-delete" + const val PASSWORD_VALUE = "identity-password-value" + const val SAVE_PASSWORD = "identity-save-password" + const val DELETE_PASSWORD_PREFIX = "identity-delete-password-" + const val CONFIRM_DELETE_PASSWORD = "identity-confirm-delete-password" const val UNCONFIGURED = "identity-auth-unconfigured" const val PASSWORD = "identity-auth-password" const val IMPORTED_KEY = "identity-auth-imported-key" @@ -420,6 +579,7 @@ private fun SshIdentity.toDraft() = SshIdentityDraft( username = username, authenticationMethod = authenticationMethod, importedPrivateKeyId = importedPrivateKeyId, + hasSavedPassword = hasSavedPassword, ) private fun IdentityAuthenticationMethod.label(): String = when (this) { diff --git a/app/src/main/java/dev/threadline/MainActivity.kt b/app/src/main/java/dev/threadline/MainActivity.kt index 2f75896..5cca617 100644 --- a/app/src/main/java/dev/threadline/MainActivity.kt +++ b/app/src/main/java/dev/threadline/MainActivity.kt @@ -1,9 +1,11 @@ package dev.threadline import android.Manifest +import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.net.Uri import android.os.Build @@ -29,6 +31,7 @@ import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -40,6 +43,7 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedSecureTextField import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -56,6 +60,7 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -94,6 +99,9 @@ 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.key.SavedSshPasswordPromptPurpose +import dev.threadline.data.key.authenticateSavedSshPasswordUse +import dev.threadline.data.key.supportsSavedSshPasswords import dev.threadline.data.profile.SavedHostProfile import dev.threadline.data.transcript.SavedTranscriptSession import dev.threadline.data.transcript.SavedTranscriptSessionSummary @@ -272,6 +280,9 @@ internal object HostKeyDialogTags { @Composable private fun ThreadlineApp() { val context = androidx.compose.ui.platform.LocalContext.current + var savedPasswordSupported by remember(context) { + mutableStateOf(supportsSavedSshPasswords(context)) + } val manager = SessionRuntime.manager val snapshot by manager.snapshot.collectAsStateWithLifecycle() val importedPrivateKeys by SessionRuntime.importedPrivateKeys.keys @@ -355,6 +366,7 @@ private fun ThreadlineApp() { } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + savedPasswordSupported = supportsSavedSshPasswords(context) notificationPermissionGranted = hasSessionNotificationPermission(context) if (notificationPermissionGranted && notificationPermissionDenialCount > 0) { notificationPermissionDenialCount = 0 @@ -429,6 +441,7 @@ private fun ThreadlineApp() { onClearTranscriptHistory = SessionRuntime.transcriptHistory::clearAll, importedPrivateKeys = importedPrivateKeys, sshIdentities = sshIdentities, + savedPasswordSupported = savedPasswordSupported, onSaveSshIdentity = { identity -> if (identity.id == null) { SessionRuntime.sshIdentities.save( @@ -463,7 +476,33 @@ private fun ThreadlineApp() { }, ) }, - onDeleteSshIdentity = SessionRuntime.sshIdentities::delete, + onSaveSavedPassword = { identityId, password -> + val activity = context.findActivity() + ?: throw IllegalStateException("Device approval is unavailable.") + SessionRuntime.savedSshPasswords.save(identityId, password) { cipher -> + authenticateSavedSshPasswordUse( + activity, + cipher, + SavedSshPasswordPromptPurpose.SAVE, + ) + } + }, + onDeleteSavedPassword = SessionRuntime.savedSshPasswords::delete, + onLoadSavedSshPassword = { identityId -> + val activity = context.findActivity() + ?: throw IllegalStateException("Device approval is unavailable.") + SessionRuntime.savedSshPasswords.credential(identityId) { cipher -> + authenticateSavedSshPasswordUse( + activity, + cipher, + SavedSshPasswordPromptPurpose.CONNECT, + ) + } + }, + onDeleteSshIdentity = { id -> + SessionRuntime.savedSshPasswords.delete(id) + SessionRuntime.sshIdentities.delete(id) + }, onSavePrivateKey = SessionRuntime.importedPrivateKeys::save, onLoadPrivateKey = SessionRuntime.importedPrivateKeys::credential, onRenamePrivateKey = SessionRuntime.importedPrivateKeys::rename, @@ -577,6 +616,12 @@ private fun openNotificationSettings(context: Context) { context.startActivity(intent) } +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + @Composable private fun NotificationPermissionCard( state: SessionNotificationPermissionState, @@ -700,9 +745,16 @@ internal fun HostForm( }, importedPrivateKeys: List = emptyList(), sshIdentities: List = emptyList(), + savedPasswordSupported: Boolean = false, onSaveSshIdentity: suspend (SshIdentityDraft) -> Unit = { error("SSH identity storage is unavailable.") }, + onSaveSavedPassword: suspend (identityId: String, password: CharArray) -> Unit = { _, _ -> + error("Saved SSH password storage is unavailable.") + }, + onDeleteSavedPassword: suspend (identityId: String) -> Unit = { + error("Saved SSH password storage is unavailable.") + }, onCreateDefaultSshIdentity: suspend ( profile: HostProfile, authenticationMode: AuthenticationMode, @@ -713,6 +765,9 @@ internal fun HostForm( onDeleteSshIdentity: suspend (id: String) -> Unit = { error("SSH identity storage is unavailable.") }, + onLoadSavedSshPassword: suspend (identityId: String) -> SessionCredential.Password = { + error("Saved SSH password storage is unavailable.") + }, onSavePrivateKey: suspend ( displayName: String, keyBytes: ByteArray, @@ -746,7 +801,7 @@ internal fun HostForm( val context = androidx.compose.ui.platform.LocalContext.current val coroutineScope = rememberCoroutineScope() // Secrets deliberately use remember rather than rememberSaveable. - var password by remember { mutableStateOf("") } + var passwordState by remember { mutableStateOf(TextFieldState()) } var keyPassphrase by remember { mutableStateOf("") } var selectedKeyUri by rememberSaveable { mutableStateOf(null) } var selectedSavedKeyId by rememberSaveable { mutableStateOf(null) } @@ -773,6 +828,9 @@ internal fun HostForm( val privateKeyBringIntoViewRequester = remember { BringIntoViewRequester() } val keyPassphraseFocusRequester = remember { FocusRequester() } val selectedHostProfile = hostProfiles.firstOrNull { it.id == selectedHostProfileId } + val selectedPreferredIdentity = sshIdentities.firstOrNull { + it.id == selectedPreferredIdentityId + } val isBusy = isPreparing || isManagingProfile || isManagingKnownHost || isManagingKey val restoredTask = HomeTask.valueOf(savedTask) val task = if ( @@ -791,7 +849,7 @@ internal fun HostForm( } fun clearSessionCredentialInputs() { - password = "" + passwordState = TextFieldState() keyPassphrase = "" selectedKeyUri = null selectedSavedKeyId = null @@ -811,6 +869,12 @@ internal fun HostForm( connectionPreparationError = null } + LaunchedEffect(passwordState) { + snapshotFlow { passwordState.text.isNotEmpty() }.collect { hasPassword -> + if (hasPassword) clearValidationError(ConnectionValidationField.PASSWORD) + } + } + fun showValidationError(error: ConnectionValidationError) { validationError = error connectionPreparationError = null @@ -949,6 +1013,7 @@ internal fun HostForm( knownHosts = knownHosts, importedPrivateKeys = importedPrivateKeys, sshIdentities = sshIdentities, + savedPasswordSupported = savedPasswordSupported, enabled = !isBusy, error = formError, onForgetHost = { @@ -965,6 +1030,8 @@ internal fun HostForm( formError = null }, onSaveIdentity = onSaveSshIdentity, + onSaveSavedPassword = onSaveSavedPassword, + onDeleteSavedPassword = onDeleteSavedPassword, onDeleteIdentity = { id -> onDeleteSshIdentity(id) if (selectedPreferredIdentityId == id) { @@ -1222,8 +1289,9 @@ internal fun HostForm( } } Text( - "Profiles save the server address and preferred identity. Passwords and " + - "private-key passphrases are entered for each connection.", + "Profiles save the server address and preferred identity. Passwords are " + + "entered for each connection unless you opt in to an encrypted saved copy. " + + "Private-key passphrases are entered for each connection.", style = MaterialTheme.typography.bodySmall, ) @@ -1271,20 +1339,24 @@ internal fun HostForm( } when (draft.authenticationMode) { - AuthenticationMode.PASSWORD -> OutlinedTextField( - value = password, - onValueChange = { - clearValidationError(ConnectionValidationField.PASSWORD) - password = it - }, + AuthenticationMode.PASSWORD -> OutlinedSecureTextField( + state = passwordState, label = { Text("Password") }, supportingText = validationMessage(ConnectionValidationField.PASSWORD)?.let { { ConnectionValidationMessage(it) } + } ?: selectedPreferredIdentity?.takeIf { it.hasSavedPassword }?.let { + { + Text( + if (savedPasswordSupported) { + "Leave blank to unlock the saved password for this connection." + } else { + "The saved password is unavailable here. Enter it for this connection." + }, + ) + } }, isError = validationMessage(ConnectionValidationField.PASSWORD) != null, - visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - singleLine = true, modifier = Modifier .fillMaxWidth() .focusRequester(passwordFocusRequester) @@ -1453,7 +1525,10 @@ internal fun HostForm( } if ( draft.authenticationMode == AuthenticationMode.PASSWORD && - password.isEmpty() + passwordState.text.isEmpty() && + sshIdentities.none { + it.id == selectedPreferredIdentityId && it.hasSavedPassword + } ) { showValidationError( ConnectionValidationError( @@ -1481,14 +1556,24 @@ internal fun HostForm( isPreparing = true coroutineScope.launch { + var credentialForAttempt: SessionCredential? = null + var credentialTransferred = false try { val credential = when (draft.authenticationMode) { AuthenticationMode.PASSWORD -> { - val characters = password.toCharArray() - try { - SessionCredential.Password.from(characters) - } finally { - characters.fill('\u0000') + if (passwordState.text.isNotEmpty()) { + val characters = CharArray(passwordState.text.length) { + passwordState.text[it] + } + try { + SessionCredential.Password.from(characters) + } finally { + characters.fill('\u0000') + } + } else { + val identityId = selectedPreferredIdentityId + ?: error("Enter the password for this connection.") + onLoadSavedSshPassword(identityId) } } @@ -1529,6 +1614,7 @@ internal fun HostForm( } } } + credentialForAttempt = credential val request = ConnectionRequest( profile = profile, @@ -1536,7 +1622,8 @@ internal fun HostForm( ephemeral = draft.ephemeral, ) if (onPrepared(request)) { - password = "" + credentialTransferred = true + passwordState = TextFieldState() keyPassphrase = "" } else { connectionPreparationError = @@ -1548,6 +1635,7 @@ internal fun HostForm( connectionPreparationError = failure.message ?: "Could not prepare the selected private key." } finally { + if (!credentialTransferred) credentialForAttempt?.clear() isPreparing = false } } @@ -1872,7 +1960,7 @@ private fun HomeOverviewContent( if (hostProfiles.isEmpty()) { Text("No saved connections yet. Start with a new connection.") } else { - Text("Choose a saved connection. Credentials are entered each time.") + Text("Choose a saved connection. Credentials are entered or unlocked each time.") hostProfiles.forEach { profile -> OutlinedButton( onClick = { onOpenProfile(profile) }, @@ -1961,12 +2049,15 @@ private fun SecurityManagementContent( knownHosts: List, importedPrivateKeys: List, sshIdentities: List, + savedPasswordSupported: Boolean, enabled: Boolean, error: String?, onForgetHost: (KnownHostMetadata) -> Unit, onRenameKey: (ImportedPrivateKeyMetadata) -> Unit, onDeleteKey: (ImportedPrivateKeyMetadata) -> Unit, onSaveIdentity: suspend (SshIdentityDraft) -> Unit, + onSaveSavedPassword: suspend (String, CharArray) -> Unit, + onDeleteSavedPassword: suspend (String) -> Unit, onDeleteIdentity: suspend (String) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { @@ -2079,8 +2170,11 @@ private fun SecurityManagementContent( SshIdentityManagementContent( identities = sshIdentities, importedPrivateKeys = importedPrivateKeys, + savedPasswordSupported = savedPasswordSupported, enabled = enabled, onSaveIdentity = onSaveIdentity, + onSaveSavedPassword = onSaveSavedPassword, + onDeleteSavedPassword = onDeleteSavedPassword, onDeleteIdentity = onDeleteIdentity, ) } diff --git a/app/src/main/java/dev/threadline/SessionRuntime.kt b/app/src/main/java/dev/threadline/SessionRuntime.kt index f4525cb..21c3490 100644 --- a/app/src/main/java/dev/threadline/SessionRuntime.kt +++ b/app/src/main/java/dev/threadline/SessionRuntime.kt @@ -10,7 +10,9 @@ 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.AndroidKeystoreSshPasswordCipher import dev.threadline.data.key.EncryptedImportedPrivateKeyStore +import dev.threadline.data.key.EncryptedSshPasswordStore import dev.threadline.data.profile.RoomHostProfileStore import dev.threadline.data.transcript.RoomTranscriptHistoryStore @@ -27,6 +29,9 @@ object SessionRuntime { internal lateinit var importedPrivateKeys: EncryptedImportedPrivateKeyStore private set + internal lateinit var savedSshPasswords: EncryptedSshPasswordStore + private set + internal lateinit var knownHosts: RoomKnownHostStore private set @@ -52,6 +57,10 @@ object SessionRuntime { dao = threadlineDatabase.importedPrivateKeys(), cipher = AndroidKeystorePrivateKeyCipher(), ) + val savedSshPasswordStore = EncryptedSshPasswordStore( + dao = threadlineDatabase.savedSshPasswords(), + cipher = AndroidKeystoreSshPasswordCipher(), + ) val hostProfileStore = RoomHostProfileStore(threadlineDatabase.hostProfiles()) val sshIdentityStore = RoomSshIdentityStore(threadlineDatabase.sshIdentities()) val transcriptHistoryStore = RoomTranscriptHistoryStore( @@ -77,6 +86,7 @@ object SessionRuntime { database = threadlineDatabase importedPrivateKeys = importedKeyStore + savedSshPasswords = savedSshPasswordStore knownHosts = knownHostStore hostProfiles = hostProfileStore sshIdentities = sshIdentityStore 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 2c7815c..09a64ff 100644 --- a/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt +++ b/app/src/main/java/dev/threadline/data/db/ThreadlineDatabase.kt @@ -186,6 +186,45 @@ internal data class SshIdentityEntity( val updatedAtMillis: Long, ) +@Entity( + tableName = "saved_ssh_passwords", + foreignKeys = [ + ForeignKey( + entity = SshIdentityEntity::class, + parentColumns = ["id"], + childColumns = ["identity_id"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +internal data class SavedSshPasswordEntity( + @PrimaryKey + @ColumnInfo(name = "identity_id") + val identityId: String, + @ColumnInfo(name = "ciphertext", typeAffinity = ColumnInfo.BLOB) + val ciphertext: ByteArray, + @ColumnInfo(name = "initialization_vector", typeAffinity = ColumnInfo.BLOB) + val initializationVector: ByteArray, + @ColumnInfo(name = "crypto_version") + val cryptoVersion: Int, +) + +internal data class SshIdentityRow( + 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, + @ColumnInfo(name = "has_saved_password") + val hasSavedPassword: Boolean, +) + internal data class HostProfileRow( val id: String, @ColumnInfo(name = "display_name") @@ -205,11 +244,16 @@ internal data class HostProfileRow( internal interface SshIdentityDao { @Query( """ - SELECT * FROM ssh_identities + SELECT ssh_identities.*, + EXISTS( + SELECT 1 FROM saved_ssh_passwords + WHERE saved_ssh_passwords.identity_id = ssh_identities.id + ) AS has_saved_password + FROM ssh_identities ORDER BY label COLLATE NOCASE, username COLLATE NOCASE, id """, ) - fun observeAll(): Flow> + fun observeAll(): Flow> @Query("SELECT * FROM ssh_identities WHERE id = :id") suspend fun find(id: String): SshIdentityEntity? @@ -250,6 +294,18 @@ internal interface SshIdentityDao { } } +@Dao +internal interface SavedSshPasswordDao { + @Query("SELECT * FROM saved_ssh_passwords WHERE identity_id = :identityId") + suspend fun find(identityId: String): SavedSshPasswordEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: SavedSshPasswordEntity) + + @Query("DELETE FROM saved_ssh_passwords WHERE identity_id = :identityId") + suspend fun delete(identityId: String): Int +} + @Dao internal interface HostProfileDao { @Query( @@ -509,11 +565,12 @@ internal interface TranscriptArchiveDao { ImportedPrivateKeyEntity::class, HostProfileEntity::class, SshIdentityEntity::class, + SavedSshPasswordEntity::class, TranscriptSessionEntity::class, TranscriptTurnEntity::class, TranscriptOutputChunkEntity::class, ], - version = 6, + version = 7, exportSchema = true, ) internal abstract class ThreadlineDatabase : RoomDatabase() { @@ -521,6 +578,7 @@ internal abstract class ThreadlineDatabase : RoomDatabase() { abstract fun importedPrivateKeys(): ImportedPrivateKeyDao abstract fun hostProfiles(): HostProfileDao abstract fun sshIdentities(): SshIdentityDao + abstract fun savedSshPasswords(): SavedSshPasswordDao abstract fun transcriptArchives(): TranscriptArchiveDao companion object { @@ -537,6 +595,7 @@ internal abstract class ThreadlineDatabase : RoomDatabase() { MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, + MIGRATION_6_7, ).build() internal val MIGRATION_1_2 = object : Migration(1, 2) { @@ -703,5 +762,23 @@ internal abstract class ThreadlineDatabase : RoomDatabase() { ) } } + + internal val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `saved_ssh_passwords` ( + `identity_id` TEXT NOT NULL, + `ciphertext` BLOB NOT NULL, + `initialization_vector` BLOB NOT NULL, + `crypto_version` INTEGER NOT NULL, + PRIMARY KEY(`identity_id`), + FOREIGN KEY(`identity_id`) REFERENCES `ssh_identities`(`id`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent(), + ) + } + } } } diff --git a/app/src/main/java/dev/threadline/data/identity/RoomSshIdentityStore.kt b/app/src/main/java/dev/threadline/data/identity/RoomSshIdentityStore.kt index 51a79a8..93be9b6 100644 --- a/app/src/main/java/dev/threadline/data/identity/RoomSshIdentityStore.kt +++ b/app/src/main/java/dev/threadline/data/identity/RoomSshIdentityStore.kt @@ -2,6 +2,7 @@ package dev.threadline.data.identity import dev.threadline.data.db.SshIdentityDao import dev.threadline.data.db.SshIdentityEntity +import dev.threadline.data.db.SshIdentityRow import java.util.UUID import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -24,6 +25,7 @@ internal data class SshIdentity( val importedPrivateKeyId: String?, val createdAtMillis: Long, val updatedAtMillis: Long, + val hasSavedPassword: Boolean = false, ) internal class RoomSshIdentityStore( @@ -33,7 +35,7 @@ internal class RoomSshIdentityStore( private val newId: () -> String = { UUID.randomUUID().toString() }, ) { val identities: Flow> = dao.observeAll().map { entities -> - entities.map(SshIdentityEntity::toSshIdentity) + entities.map(SshIdentityRow::toSshIdentity) } suspend fun save( @@ -136,6 +138,17 @@ private fun SshIdentityEntity.toSshIdentity() = SshIdentity( updatedAtMillis = updatedAtMillis, ) +private fun SshIdentityRow.toSshIdentity() = SshIdentity( + id = id, + label = label, + username = username, + authenticationMethod = IdentityAuthenticationMethod.valueOf(authenticationMethod), + importedPrivateKeyId = importedPrivateKeyId, + createdAtMillis = createdAtMillis, + updatedAtMillis = updatedAtMillis, + hasSavedPassword = hasSavedPassword, +) + private suspend inline fun protectIdentityStorage( message: String, crossinline operation: suspend () -> T, diff --git a/app/src/main/java/dev/threadline/data/key/AndroidKeystoreSshPasswordCipher.kt b/app/src/main/java/dev/threadline/data/key/AndroidKeystoreSshPasswordCipher.kt new file mode 100644 index 0000000..5834985 --- /dev/null +++ b/app/src/main/java/dev/threadline/data/key/AndroidKeystoreSshPasswordCipher.kt @@ -0,0 +1,266 @@ +package dev.threadline.data.key + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import androidx.annotation.RequiresApi +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.security.InvalidKeyException +import java.security.KeyStore +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal data class EncryptedSshPassword( + val ciphertext: ByteArray, + val initializationVector: ByteArray, +) + +internal interface SshPasswordCipher { + suspend fun encrypt( + identityId: String, + password: CharArray, + authorize: suspend (Cipher) -> Cipher, + ): EncryptedSshPassword + + suspend fun decrypt( + identityId: String, + encrypted: EncryptedSshPassword, + authorize: suspend (Cipher) -> Cipher, + ): CharArray + + suspend fun deleteKey(identityId: String) +} + +internal class AndroidKeystoreSshPasswordCipher( + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : SshPasswordCipher { + override suspend fun encrypt( + identityId: String, + password: CharArray, + authorize: suspend (Cipher) -> Cipher, + ): EncryptedSshPassword { + require(identityId.isNotBlank()) + require(password.isNotEmpty()) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + throw SavedSshPasswordUnavailableException() + } + val aad = associatedData(identityId) + try { + val cipher = withContext(ioDispatcher) { + newEncryptionCipher(identityId, recoverInvalidatedKey = true) + } + val authorizedCipher = authorize(cipher) + val plaintext = encode(password) + return try { + withContext(ioDispatcher) { + authorizedCipher.updateAAD(aad) + EncryptedSshPassword( + ciphertext = authorizedCipher.doFinal(plaintext), + initializationVector = authorizedCipher.iv.copyOf(), + ) + } + } finally { + plaintext.fill(0) + } + } catch (invalidated: KeyPermanentlyInvalidatedException) { + throw SavedSshPasswordKeyInvalidatedException(invalidated) + } catch (failure: InvalidKeyException) { + throw SavedSshPasswordProtectionException(failure) + } catch (failure: Exception) { + if (failure is SavedSshPasswordException) throw failure + throw SavedSshPasswordProtectionException(failure) + } finally { + aad.fill(0) + } + } + + override suspend fun decrypt( + identityId: String, + encrypted: EncryptedSshPassword, + authorize: suspend (Cipher) -> Cipher, + ): CharArray { + require(identityId.isNotBlank()) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + throw SavedSshPasswordUnavailableException() + } + val aad = associatedData(identityId) + try { + val cipher = withContext(ioDispatcher) { + require(encrypted.initializationVector.size == GCM_IV_BYTES) + require(encrypted.ciphertext.size >= GCM_TAG_BYTES) + val key = findKey(keyAlias(identityId)) + ?: throw SavedSshPasswordUnavailableException() + Cipher.getInstance(TRANSFORMATION).apply { + init( + Cipher.DECRYPT_MODE, + key, + GCMParameterSpec(GCM_TAG_BITS, encrypted.initializationVector), + ) + } + } + val authorizedCipher = authorize(cipher) + val plaintext = withContext(ioDispatcher) { + authorizedCipher.updateAAD(aad) + authorizedCipher.doFinal(encrypted.ciphertext) + } + return try { + decode(plaintext) + } finally { + plaintext.fill(0) + } + } catch (unavailable: SavedSshPasswordUnavailableException) { + throw unavailable + } catch (invalidated: KeyPermanentlyInvalidatedException) { + throw SavedSshPasswordKeyInvalidatedException(invalidated) + } catch (failure: InvalidKeyException) { + throw SavedSshPasswordProtectionException(failure) + } catch (failure: Exception) { + if (failure is SavedSshPasswordException) throw failure + throw SavedSshPasswordProtectionException(failure) + } finally { + aad.fill(0) + } + } + + override suspend fun deleteKey(identityId: String) = withContext(ioDispatcher) { + require(identityId.isNotBlank()) + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + keyStore.deleteEntry(keyAlias(identityId)) + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun newEncryptionCipher( + identityId: String, + recoverInvalidatedKey: Boolean, + ): Cipher { + val alias = keyAlias(identityId) + val cipher = Cipher.getInstance(TRANSFORMATION) + try { + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey(alias)) + } catch (invalidated: KeyPermanentlyInvalidatedException) { + if (!recoverInvalidatedKey) throw invalidated + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + keyStore.deleteEntry(alias) + cipher.init(Cipher.ENCRYPT_MODE, createKey(alias)) + } + return cipher + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getOrCreateKey(alias: String): SecretKey = + findKey(alias) ?: createKey(alias) + + private fun findKey(alias: String): SecretKey? { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + return keyStore.getKey(alias, null) as? SecretKey + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun createKey(alias: String): SecretKey = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE, + ).run { + init( + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(AES_KEY_BITS) + .setRandomizedEncryptionRequired(true) + .setUserAuthenticationRequired(true) + .setUserAuthenticationParameters( + 0, + KeyProperties.AUTH_BIOMETRIC_STRONG or + KeyProperties.AUTH_DEVICE_CREDENTIAL, + ) + .build(), + ) + generateKey() + } + + private fun keyAlias(identityId: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(identityId.encodeToByteArray()) + return try { + ALIAS_PREFIX + digest.joinToString("") { byte -> + "%02x".format(byte.toInt() and 0xff) + } + } finally { + digest.fill(0) + } + } + + private fun associatedData(identityId: String): ByteArray = + ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use { output -> + output.writeUTF("Threadline saved SSH password") + output.writeInt(CRYPTO_VERSION) + output.writeUTF(identityId) + } + bytes.toByteArray() + } + + private fun encode(password: CharArray): ByteArray { + val encoded = Charsets.UTF_8.newEncoder().encode(CharBuffer.wrap(password)) + return try { + ByteArray(encoded.remaining()).also(encoded::get) + } finally { + if (encoded.hasArray()) encoded.array().fill(0) + } + } + + private fun decode(plaintext: ByteArray): CharArray { + val decoded = Charsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(plaintext)) + return try { + CharArray(decoded.remaining()).also(decoded::get) + } finally { + if (decoded.hasArray()) decoded.array().fill('\u0000') + } + } + + companion object { + internal const val CRYPTO_VERSION = 1 + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val ALIAS_PREFIX = "threadline.saved-ssh-password.v1." + private const val AES_KEY_BITS = 256 + private const val GCM_TAG_BITS = 128 + private const val GCM_TAG_BYTES = GCM_TAG_BITS / 8 + private const val GCM_IV_BYTES = 12 + } +} + +internal sealed class SavedSshPasswordException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) + +internal class SavedSshPasswordUnavailableException : SavedSshPasswordException( + "The saved password is unavailable on this device. Enter it again or replace it.", +) + +internal class SavedSshPasswordKeyInvalidatedException( + cause: Throwable, +) : SavedSshPasswordException( + "Device security changed. Enter the password again to replace the saved copy.", + cause, +) + +internal class SavedSshPasswordProtectionException( + cause: Throwable, +) : SavedSshPasswordException( + "The saved password could not be protected by this device.", + cause, +) diff --git a/app/src/main/java/dev/threadline/data/key/AndroidSavedSshPasswordAuthenticator.kt b/app/src/main/java/dev/threadline/data/key/AndroidSavedSshPasswordAuthenticator.kt new file mode 100644 index 0000000..dad718c --- /dev/null +++ b/app/src/main/java/dev/threadline/data/key/AndroidSavedSshPasswordAuthenticator.kt @@ -0,0 +1,104 @@ +package dev.threadline.data.key + +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.hardware.biometrics.BiometricManager +import android.hardware.biometrics.BiometricPrompt +import android.os.Build +import android.os.CancellationSignal +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import javax.crypto.Cipher +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +internal fun supportsSavedSshPasswords(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + val keyguard = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + return keyguard.isDeviceSecure +} + +internal suspend fun authenticateSavedSshPasswordUse( + activity: Activity, + cipher: Cipher, + purpose: SavedSshPasswordPromptPurpose, +): Cipher { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + throw SavedSshPasswordAuthenticationUnavailableException() + } + if (!supportsSavedSshPasswords(activity)) { + throw SavedSshPasswordAuthenticationUnavailableException() + } + return authenticateOnAndroid11(activity, cipher, purpose) +} + +internal enum class SavedSshPasswordPromptPurpose { + SAVE, + CONNECT, +} + +@RequiresApi(Build.VERSION_CODES.R) +private suspend fun authenticateOnAndroid11( + activity: Activity, + cipher: Cipher, + purpose: SavedSshPasswordPromptPurpose, +): Cipher = suspendCancellableCoroutine { continuation -> + val cancellationSignal = CancellationSignal() + val prompt = BiometricPrompt.Builder(activity) + .setTitle( + when (purpose) { + SavedSshPasswordPromptPurpose.SAVE -> "Save SSH password" + SavedSshPasswordPromptPurpose.CONNECT -> "Unlock SSH password" + }, + ) + .setSubtitle("Approve this operation with device credentials or a strong biometric") + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL, + ) + .build() + + continuation.invokeOnCancellation { cancellationSignal.cancel() } + prompt.authenticate( + BiometricPrompt.CryptoObject(cipher), + cancellationSignal, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + if (!continuation.isActive) return + val authorizedCipher = result.cryptoObject?.cipher + if (authorizedCipher == null) { + continuation.resumeWithException( + SavedSshPasswordAuthenticationUnavailableException(), + ) + } else if (continuation.isActive) { + continuation.resume(authorizedCipher) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (!continuation.isActive) return + continuation.resumeWithException( + if ( + errorCode == BiometricPrompt.BIOMETRIC_ERROR_USER_CANCELED || + errorCode == BiometricPrompt.BIOMETRIC_ERROR_CANCELED + ) { + SavedSshPasswordAuthenticationCancelledException() + } else { + SavedSshPasswordAuthenticationUnavailableException() + }, + ) + } + }, + ) +} + +internal class SavedSshPasswordAuthenticationCancelledException : SavedSshPasswordException( + "Device approval was canceled. Enter the password for this connection or try again.", +) + +internal class SavedSshPasswordAuthenticationUnavailableException : SavedSshPasswordException( + "Device approval is unavailable. Enter the password for this connection instead.", +) diff --git a/app/src/main/java/dev/threadline/data/key/EncryptedSshPasswordStore.kt b/app/src/main/java/dev/threadline/data/key/EncryptedSshPasswordStore.kt new file mode 100644 index 0000000..f54f7ca --- /dev/null +++ b/app/src/main/java/dev/threadline/data/key/EncryptedSshPasswordStore.kt @@ -0,0 +1,100 @@ +package dev.threadline.data.key + +import dev.threadline.core.model.SessionCredential +import dev.threadline.data.db.SavedSshPasswordDao +import dev.threadline.data.db.SavedSshPasswordEntity +import java.util.concurrent.CancellationException +import javax.crypto.Cipher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class EncryptedSshPasswordStore( + private val dao: SavedSshPasswordDao, + private val cipher: SshPasswordCipher, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + suspend fun save( + identityId: String, + password: CharArray, + authorize: suspend (Cipher) -> Cipher, + ) { + try { + require(identityId.isNotBlank()) + require(password.isNotEmpty()) { "Enter a password to save." } + val encrypted = cipher.encrypt(identityId, password, authorize) + try { + protectSavedPasswordStorage("The SSH password could not be saved securely.") { + dao.upsert( + SavedSshPasswordEntity( + identityId = identityId, + ciphertext = encrypted.ciphertext, + initializationVector = encrypted.initializationVector, + cryptoVersion = AndroidKeystoreSshPasswordCipher.CRYPTO_VERSION, + ), + ) + } + } finally { + encrypted.ciphertext.fill(0) + encrypted.initializationVector.fill(0) + } + } finally { + password.fill('\u0000') + } + } + + suspend fun credential( + identityId: String, + authorize: suspend (Cipher) -> Cipher, + ): SessionCredential.Password { + require(identityId.isNotBlank()) + val stored = protectSavedPasswordStorage("The saved SSH password could not be read.") { + withContext(ioDispatcher) { dao.find(identityId) } + } ?: throw SavedSshPasswordUnavailableException() + require(stored.cryptoVersion == AndroidKeystoreSshPasswordCipher.CRYPTO_VERSION) { + "This saved SSH password uses an unsupported encryption version." + } + val plaintext = cipher.decrypt( + identityId = identityId, + encrypted = EncryptedSshPassword( + ciphertext = stored.ciphertext, + initializationVector = stored.initializationVector, + ), + authorize = authorize, + ) + return try { + SessionCredential.Password.from(plaintext) + } finally { + plaintext.fill('\u0000') + } + } + + suspend fun delete(identityId: String) { + require(identityId.isNotBlank()) + // If Room cleanup fails, the ciphertext is left without a usable key. + protectSavedPasswordStorage("The saved SSH password key could not be removed.") { + cipher.deleteKey(identityId) + } + protectSavedPasswordStorage("The saved SSH password could not be removed.") { + withContext(ioDispatcher) { dao.delete(identityId) } + } + } +} + +internal class SavedSshPasswordStorageException( + message: String, + cause: Throwable, +) : Exception(message, cause) + +private suspend inline fun protectSavedPasswordStorage( + message: String, + crossinline operation: suspend () -> T, +): T = try { + operation() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: SavedSshPasswordException) { + throw failure +} catch (failure: Exception) { + throw SavedSshPasswordStorageException(message, failure) +}