Skip to content

Commit f690f4c

Browse files
committed
feat: Add support for importing SSH keys from files #1326
1 parent d4dc2b7 commit f690f4c

13 files changed

Lines changed: 1182 additions & 69 deletions

File tree

common/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ kotlin {
172172
implementation(libs.commons.codec)
173173
implementation(libs.bouncycastle.bcpkix)
174174
implementation(libs.bouncycastle.bcprov)
175+
implementation(libs.hierynomus.sshj)
175176
implementation(libs.ricecode.string.similarity)
176177
implementation(libs.google.zxing.core)
177178
// SignalR

common/src/commonMain/composeResources/values/strings.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,19 @@
458458
<string name="ssh_key_action_save_unencrypted_private_key_saved_downloads_success_title">Unencrypted private key saved to Downloads</string>
459459
<string name="ssh_key_action_add_key_title">Add keys</string>
460460
<string name="ssh_key_action_replace_key_title">Replace keys</string>
461+
<string name="ssh_key_import_title">Import SSH key</string>
462+
<string name="ssh_key_import_failed_title">Failed to import SSH key</string>
463+
<string name="ssh_key_import_success_title">Imported SSH key</string>
464+
<string name="ssh_key_import_error_unsupported_format">Unsupported SSH private key format.</string>
465+
<string name="ssh_key_import_error_unsupported_algorithm">Only RSA and Ed25519 SSH keys are supported.</string>
466+
<string name="ssh_key_import_error_invalid_passphrase">The SSH key passphrase is invalid.</string>
467+
<string name="ssh_key_import_error_malformed_key">The SSH key file is malformed.</string>
468+
<string name="ssh_key_import_error_read">Unable to read the selected file.</string>
469+
<string name="ssh_key_import_error_passphrase_required">A passphrase is required to unlock this SSH key.</string>
470+
<string name="ssh_key_import_passphrase_title">Passphrase</string>
471+
<string name="ssh_key_import_passphrase_hint">Enter SSH key passphrase</string>
472+
<string name="ssh_key_import_passphrase_dialog_title">Enter SSH key passphrase</string>
473+
<string name="ssh_key_import_passphrase_dialog_message">This <xliff:g id="format_label" example="OpenSSH">%1$s</xliff:g> key is encrypted.</string>
461474

462475
<string name="selection_n_selected"><xliff:g id="size" example="15">%1$d</xliff:g> selected</string>
463476

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.artemchep.keyguard.common.service.crypto
2+
3+
import com.artemchep.keyguard.common.model.KeyPair
4+
5+
interface SshKeyImportService {
6+
fun import(
7+
request: SshKeyImportRequest,
8+
): SshKeyImportResult
9+
}
10+
11+
data class SshKeyImportRequest(
12+
val content: String,
13+
val fileName: String? = null,
14+
val passphrase: String? = null,
15+
)
16+
17+
sealed interface SshKeyImportResult {
18+
data class Success(
19+
val keyPair: KeyPair,
20+
) : SshKeyImportResult
21+
22+
data class NeedsPassphrase(
23+
val formatLabel: String,
24+
) : SshKeyImportResult
25+
26+
data class Error(
27+
val reason: SshKeyImportError,
28+
) : SshKeyImportResult
29+
}
30+
31+
enum class SshKeyImportError {
32+
UnsupportedFormat,
33+
UnsupportedAlgorithm,
34+
InvalidPassphrase,
35+
MalformedKey,
36+
}

common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreen.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import androidx.compose.material3.ButtonDefaults
4343
import androidx.compose.material3.Checkbox
4444
import androidx.compose.material3.DropdownMenu
4545
import androidx.compose.material3.Icon
46+
import androidx.compose.material3.IconButton
4647
import androidx.compose.material3.LocalContentColor
4748
import androidx.compose.material3.LocalTextStyle
4849
import androidx.compose.material3.MaterialTheme
@@ -820,6 +821,14 @@ private fun SshKeyField(
820821
}
821822
},
822823
)
824+
IconButton(
825+
onClick = state.onImport,
826+
) {
827+
Icon(
828+
imageVector = Icons.Outlined.FileUpload,
829+
contentDescription = stringResource(Res.string.ssh_key_import_title),
830+
)
831+
}
823832
},
824833
enabled = true,
825834
)

common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddStateProducer.kt

Lines changed: 187 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,15 @@ import com.artemchep.keyguard.common.model.creditCards
7575
import com.artemchep.keyguard.common.model.fileName
7676
import com.artemchep.keyguard.common.model.fileSize
7777
import com.artemchep.keyguard.common.model.titleH
78+
import com.artemchep.keyguard.common.service.crypto.SshKeyImportError
79+
import com.artemchep.keyguard.common.service.crypto.SshKeyImportRequest
80+
import com.artemchep.keyguard.common.service.crypto.SshKeyImportResult
81+
import com.artemchep.keyguard.common.service.crypto.SshKeyImportService
7882
import com.artemchep.keyguard.common.service.clipboard.ClipboardService
7983
import com.artemchep.keyguard.common.service.googleauthenticator.OtpMigrationService
8084
import com.artemchep.keyguard.common.service.logging.LogRepository
85+
import com.artemchep.keyguard.common.service.text.TextService
86+
import com.artemchep.keyguard.common.service.text.readFromFileAsText
8187
import com.artemchep.keyguard.common.usecase.AddCipher
8288
import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck
8389
import com.artemchep.keyguard.common.usecase.CopyText
@@ -169,6 +175,7 @@ import kotlinx.coroutines.flow.onStart
169175
import kotlinx.coroutines.flow.scan
170176
import kotlinx.coroutines.flow.shareIn
171177
import kotlinx.coroutines.flow.update
178+
import kotlinx.coroutines.launch
172179
import kotlin.time.Clock
173180
import kotlin.time.Instant
174181
import kotlinx.serialization.SerialName
@@ -194,6 +201,8 @@ fun produceAddScreenState(
194201
getTotpCode = instance(),
195202
getGravatarUrl = instance(),
196203
getMarkdown = instance(),
204+
textService = instance(),
205+
sshKeyImportService = instance(),
197206
logRepository = instance(),
198207
clipboardService = instance(),
199208
otpMigrationService = instance(),
@@ -225,6 +234,8 @@ fun produceAddScreenState(
225234
getTotpCode: GetTotpCode,
226235
getGravatarUrl: GetGravatarUrl,
227236
getMarkdown: GetMarkdown,
237+
textService: TextService,
238+
sshKeyImportService: SshKeyImportService,
228239
logRepository: LogRepository,
229240
clipboardService: ClipboardService,
230241
otpMigrationService: OtpMigrationService,
@@ -247,6 +258,7 @@ fun produceAddScreenState(
247258
) {
248259
val copyText = copy(clipboardService)
249260
val markdown = getMarkdown().first()
261+
val filePickerEvents = EventFlow<FilePickerIntent<*>>()
250262

251263
val ownershipFlow = produceOwnershipFlow(
252264
args = args,
@@ -316,6 +328,10 @@ fun produceAddScreenState(
316328
)
317329
val sshKeyHolder = produceSshKeyState(
318330
args = args,
331+
textService = textService,
332+
sshKeyImportService = sshKeyImportService,
333+
showMessage = showMessage,
334+
filePickerIntentSink = filePickerEvents,
319335
)
320336

321337
val typeFlow = kotlin.run {
@@ -326,10 +342,8 @@ fun produceAddScreenState(
326342
flowOf(initialValue)
327343
}
328344

329-
val filePickerIntentSink = EventFlow<FilePickerIntent<*>>()
330-
331345
val sideEffects = AddState.SideEffects(
332-
filePickerIntentFlow = filePickerIntentSink,
346+
filePickerIntentFlow = filePickerEvents,
333347
)
334348

335349
val passkeysFactories = kotlin.run {
@@ -485,7 +499,7 @@ fun produceAddScreenState(
485499
add("attachment", model)
486500
}
487501
}
488-
filePickerIntentSink.emit(intent)
502+
filePickerEvents.emit(intent)
489503
},
490504
)
491505
val item = AddStateItem.Add(
@@ -3409,10 +3423,15 @@ data class KeyPairDecor2(
34093423
data class KeyPairDecor2Brr(
34103424
val keyPair: KeyPairDecor2? = null,
34113425
val onChange: (KeyPair) -> Unit,
3426+
val onImport: () -> Unit,
34123427
)
34133428

34143429
private suspend fun RememberStateFlowScope.produceSshKeyState(
34153430
args: AddRoute.Args,
3431+
textService: TextService,
3432+
sshKeyImportService: SshKeyImportService,
3433+
showMessage: ShowMessage,
3434+
filePickerIntentSink: EventFlow<FilePickerIntent<*>>,
34163435
): TmpSshKey {
34173436
val prefix = "sshKey"
34183437

@@ -3434,19 +3453,130 @@ private suspend fun RememberStateFlowScope.produceSshKeyState(
34343453
fingerprint = args.keyPair?.publicKey?.fingerprint ?: args.initialValue?.sshKey?.fingerprint ?: "",
34353454
)
34363455
}
3456+
3457+
suspend fun importKey(
3458+
fileName: String?,
3459+
content: String,
3460+
passphrase: String?,
3461+
// callbacks
3462+
onNeedsPassphrase: suspend (SshKeyImportResult.NeedsPassphrase) -> Unit,
3463+
) = when (
3464+
val result = sshKeyImportService.import(
3465+
SshKeyImportRequest(
3466+
content = content,
3467+
fileName = fileName,
3468+
passphrase = passphrase,
3469+
),
3470+
)
3471+
) {
3472+
is SshKeyImportResult.Success -> {
3473+
val msg = ToastMessage(
3474+
type = ToastMessage.Type.SUCCESS,
3475+
title = translate(Res.string.ssh_key_import_passphrase_title),
3476+
)
3477+
showMessage.copy(msg)
3478+
// success!
3479+
sink.value = result.keyPair.toDecor()
3480+
}
3481+
3482+
is SshKeyImportResult.NeedsPassphrase -> {
3483+
// Redirect to a next flow or
3484+
// exit there!
3485+
onNeedsPassphrase(result)
3486+
}
3487+
3488+
is SshKeyImportResult.Error -> {
3489+
val msg = createLocalizedSshKeyImportErrorToast(result.reason)
3490+
showMessage.copy(msg)
3491+
}
3492+
}
3493+
3494+
suspend fun onImportKeyWithPassphrase(
3495+
result: SshKeyImportResult.NeedsPassphrase,
3496+
fileName: String?,
3497+
content: String,
3498+
) {
3499+
val passphraseTitle = translate(Res.string.ssh_key_import_passphrase_title)
3500+
val passphraseHint = translate(Res.string.ssh_key_import_passphrase_hint)
3501+
3502+
val intent = createConfirmationDialogIntent(
3503+
item = ConfirmationRoute.Args.Item.StringItem(
3504+
key = "$id.passphrase",
3505+
title = passphraseTitle,
3506+
hint = passphraseHint,
3507+
type = ConfirmationRoute.Args.Item.StringItem.Type.Password,
3508+
canBeEmpty = false,
3509+
),
3510+
title = translate(Res.string.ssh_key_import_passphrase_dialog_title),
3511+
message = translate(
3512+
Res.string.ssh_key_import_passphrase_dialog_message,
3513+
result.formatLabel,
3514+
),
3515+
) { passphrase ->
3516+
appScope.launch {
3517+
importKey(
3518+
content = content,
3519+
fileName = fileName,
3520+
passphrase = passphrase,
3521+
// callbacks
3522+
onNeedsPassphrase = {
3523+
// show error message
3524+
val msg = createLocalizedSshKeyImportPassphraseErrorToast()
3525+
showMessage.copy(msg)
3526+
},
3527+
)
3528+
}
3529+
}
3530+
navigate(intent)
3531+
}
3532+
3533+
fun onImportKey() {
3534+
val intent = FilePickerIntent.OpenDocument(
3535+
mimeTypes = FilePickerIntent.mimeTypesAll,
3536+
) { info ->
3537+
if (info == null) {
3538+
return@OpenDocument
3539+
}
3540+
3541+
appScope.launch {
3542+
val content = kotlin.runCatching {
3543+
val uri = info.uri.toString()
3544+
textService.readFromFileAsText(uri)
3545+
}.getOrElse {
3546+
val msg = createLocalizedSshKeyImportReadErrorToast()
3547+
showMessage.copy(msg)
3548+
return@launch
3549+
}
3550+
3551+
val fileName = info.name
3552+
importKey(
3553+
content = content,
3554+
fileName = fileName,
3555+
passphrase = null,
3556+
// callbacks
3557+
onNeedsPassphrase = { result ->
3558+
// redirect to a next flow
3559+
onImportKeyWithPassphrase(
3560+
result = result,
3561+
fileName = fileName,
3562+
content = content,
3563+
)
3564+
},
3565+
)
3566+
}
3567+
}
3568+
filePickerIntentSink.emit(intent)
3569+
}
3570+
34373571
val stateItem = LocalStateItem<KeyPairDecor2Brr, CreateRequest>(
34383572
flow = sink
34393573
.map { value ->
34403574
KeyPairDecor2Brr(
34413575
keyPair = value,
34423576
onChange = {
3443-
val new = KeyPairDecor2(
3444-
privateKey = it.privateKey.ssh,
3445-
publicKey = it.publicKey.ssh,
3446-
fingerprint = it.publicKey.fingerprint,
3447-
)
3448-
sink.value = new
3577+
sink.value = it.toDecor()
34493578
},
3579+
onImport = ::onImportKey,
34503580
)
34513581
}
34523582
.persistingStateIn(
@@ -3456,6 +3586,7 @@ private suspend fun RememberStateFlowScope.produceSshKeyState(
34563586
onChange = {
34573587
// Do nothing
34583588
},
3589+
onImport = :: onImportKey,
34593590
),
34603591
),
34613592
populator = { state ->
@@ -3491,6 +3622,52 @@ private suspend fun RememberStateFlowScope.produceSshKeyState(
34913622
)
34923623
}
34933624

3625+
suspend fun TranslatorScope.createLocalizedSshKeyImportErrorToast(
3626+
reason: SshKeyImportError,
3627+
): ToastMessage = when (reason) {
3628+
SshKeyImportError.UnsupportedFormat -> createSshKeyImportToast(
3629+
title = translate(Res.string.ssh_key_import_failed_title),
3630+
text = translate(Res.string.ssh_key_import_error_unsupported_format),
3631+
)
3632+
SshKeyImportError.UnsupportedAlgorithm -> createSshKeyImportToast(
3633+
title = translate(Res.string.ssh_key_import_failed_title),
3634+
text = translate(Res.string.ssh_key_import_error_unsupported_algorithm),
3635+
)
3636+
SshKeyImportError.InvalidPassphrase -> createSshKeyImportToast(
3637+
title = translate(Res.string.ssh_key_import_failed_title),
3638+
text = translate(Res.string.ssh_key_import_error_invalid_passphrase),
3639+
)
3640+
SshKeyImportError.MalformedKey -> createSshKeyImportToast(
3641+
title = translate(Res.string.ssh_key_import_failed_title),
3642+
text = translate(Res.string.ssh_key_import_error_malformed_key),
3643+
)
3644+
}
3645+
3646+
suspend fun TranslatorScope.createLocalizedSshKeyImportReadErrorToast(): ToastMessage = createSshKeyImportToast(
3647+
title = translate(Res.string.ssh_key_import_failed_title),
3648+
text = translate(Res.string.ssh_key_import_error_read),
3649+
)
3650+
3651+
suspend fun TranslatorScope.createLocalizedSshKeyImportPassphraseErrorToast(): ToastMessage = createSshKeyImportToast(
3652+
title = translate(Res.string.ssh_key_import_failed_title),
3653+
text = translate(Res.string.ssh_key_import_error_passphrase_required),
3654+
)
3655+
3656+
private fun createSshKeyImportToast(
3657+
title: String = "Failed to import SSH key",
3658+
text: String,
3659+
): ToastMessage = ToastMessage(
3660+
type = ToastMessage.Type.ERROR,
3661+
title = title,
3662+
text = text,
3663+
)
3664+
3665+
private fun KeyPair.toDecor() = KeyPairDecor2(
3666+
privateKey = privateKey.ssh,
3667+
publicKey = publicKey.ssh,
3668+
fingerprint = publicKey.fingerprint,
3669+
)
3670+
34943671
suspend fun <Request> RememberStateFlowScope.createItem(
34953672
prefix: String,
34963673
key: String,

0 commit comments

Comments
 (0)