Server-driven auth, 3-attempt retry, remember-on-success, legacy test lab doc

Auth flow rewritten to be fully server-driven: no pre-connect password
dialog. TCP + KEX + host key verification happen first; password prompt
only appears when the server actually asks via keyboard-interactive or
the new prompted-password fallback (for servers that only accept the
"password" SSH method). Up to 3 password attempts per connection,
matching OpenSSH's NumberOfPasswordPrompts default.

"Remember password" checkbox now functional: AuthPromptResult threads
the remember flag through the callback chain; SSHSession stashes the
typed password in pendingRememberPassword; TerminalService persists it
to CredentialStore only after session.connect() succeeds — wrong
passwords are never saved.

Removed dead pre-connect dialog code: PasswordDialog composable,
PasswordResult, TerminalDialogRequest.PasswordPrompt, and
passwordPromptHandler.

Added docs/LEGACY_TEST_LAB.md: self-contained 2100-line guide for
building a dedicated server with 56 historical/modern Unix systems
for terminal parser conformance testing (Docker, QEMU/KVM, SIMH,
polarhome, IBM PowerVS). Includes all Dockerfiles, compose.yml,
SIMH configs, systemd units, and helper scripts inline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-06 19:50:18 +02:00
parent e243b8e1e5
commit 7f4aa15830
11 changed files with 2405 additions and 192 deletions

View file

@ -30,8 +30,8 @@ android {
defaultConfig {
minSdk = 27
targetSdk = 36
versionCode = 31
versionName = "0.0.31"
versionCode = 36
versionName = "0.0.36"
applicationId = "com.roundingmobile.sshworkbench"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View file

@ -2,5 +2,5 @@ package com.roundingmobile.sshworkbench
// Auto-generated — do not edit
object BuildTimestamp {
const val TIME = "2026-04-06 13:12:27"
const val TIME = "2026-04-06 19:48:44"
}

View file

@ -2,6 +2,7 @@ package com.roundingmobile.sshworkbench.terminal
import com.roundingmobile.ssh.AuthPrompt
import com.roundingmobile.ssh.AuthPromptCallback
import com.roundingmobile.ssh.AuthPromptResult
import com.roundingmobile.ssh.HostKeyAction
import com.roundingmobile.ssh.HostKeyVerifyCallback
import com.roundingmobile.ssh.SSHAuth
@ -17,7 +18,7 @@ import com.roundingmobile.sshworkbench.util.FileLogger
class SshConnectionHelper(
private val credentialStore: CredentialStore,
private val hostKeyPromptHandler: (suspend (String, Int, String, String, String?) -> HostKeyAction)?,
private val authPromptHandler: (suspend (AuthPrompt) -> List<String>?)?,
private val authPromptHandler: (suspend (AuthPrompt) -> AuthPromptResult?)?,
private val onBanner: ((String) -> Unit)? = null
) {
@ -44,7 +45,7 @@ class SshConnectionHelper(
return SSHSession().apply {
logger = { tag, msg -> FileLogger.log(tag, "[$logPrefix] $msg") }
authPromptCallback = object : AuthPromptCallback {
override suspend fun onAuthPrompt(prompt: AuthPrompt): List<String>? {
override suspend fun onAuthPrompt(prompt: AuthPrompt): AuthPromptResult? {
return authPromptHandler?.invoke(prompt)
}
override fun onBanner(banner: String) {

View file

@ -40,9 +40,6 @@ import com.roundingmobile.sshworkbench.R
/** Result from the auth prompt dialog. */
data class AuthResult(val responses: List<String>, val remember: Boolean)
/** Result from the password dialog. */
data class PasswordResult(val password: String, val remember: Boolean)
/** Requests that bridge imperative service callbacks to declarative Compose dialogs. */
sealed interface TerminalDialogRequest {
data class HostKey(
@ -60,14 +57,6 @@ sealed interface TerminalDialogRequest {
val showRemember: Boolean,
val onResult: (AuthResult?) -> Unit
) : TerminalDialogRequest
data class PasswordPrompt(
val host: String,
val username: String,
val errorMessage: String?,
val onConnect: (PasswordResult) -> Unit,
val onDisconnect: () -> Unit
) : TerminalDialogRequest
}
// Warning color — not in the Material theme, defined locally
@ -252,98 +241,6 @@ fun AuthPromptDialog(
)
}
// ============================================================================
// Password prompt dialog (auth failed / no stored password)
// ============================================================================
@Composable
fun PasswordDialog(
request: TerminalDialogRequest.PasswordPrompt,
onDismiss: () -> Unit
) {
var password by remember { mutableStateOf("") }
var rememberChecked by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { /* non-cancellable */ },
title = {
Text(stringResource(R.string.password))
},
text = {
Column {
if (request.errorMessage != null) {
Text(
text = request.errorMessage,
color = MaterialTheme.colorScheme.error,
fontSize = 13.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Text(
text = "${request.username}@${request.host}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 12.dp)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary
),
modifier = Modifier.fillMaxWidth()
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp)
) {
Checkbox(
checked = rememberChecked,
onCheckedChange = { rememberChecked = it },
colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.primary,
checkmarkColor = MaterialTheme.colorScheme.onPrimary
)
)
Text(
text = stringResource(R.string.remember_password),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp
)
}
}
},
confirmButton = {
TextButton(
onClick = {
request.onConnect(PasswordResult(password, rememberChecked))
onDismiss()
},
enabled = password.isNotEmpty()
) {
Text(stringResource(R.string.connect))
}
},
dismissButton = {
TextButton(onClick = {
request.onDisconnect()
onDismiss()
}) {
Text(stringResource(R.string.disconnect))
}
},
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.primary,
textContentColor = MaterialTheme.colorScheme.onSurface
)
}
// ============================================================================
// Shared composable helpers
// ============================================================================

View file

@ -145,16 +145,9 @@ class TerminalService : Service() {
/**
* Callback for keyboard-interactive auth prompts set by the Activity.
* Returns user responses or null to cancel.
* Returns an AuthPromptResult (responses + remember flag) or null to cancel.
*/
var authPromptHandler: (suspend (AuthPrompt) -> List<String>?)? = null
/**
* Callback for password prompt set by the Activity.
* Shown when all auth methods fail and the user needs to type a password.
* Returns PasswordResult (password + remember) or null to disconnect.
*/
var passwordPromptHandler: (suspend (host: String, username: String, errorMessage: String?) -> PasswordResult?)? = null
var authPromptHandler: (suspend (AuthPrompt) -> com.roundingmobile.ssh.AuthPromptResult?)? = null
/** Callback for server banners during auth — set by the Activity */
var onBanner: ((String) -> Unit)? = null
@ -553,26 +546,12 @@ class TerminalService : Service() {
}
serviceScope.launch(Dispatchers.IO) {
var auth = helper.buildAuth(keyId, savedConnectionId, String(password))
// If no auth method available (no key, no stored password), prompt user
if (auth is SSHAuth.None) {
val promptHandler = passwordPromptHandler
if (promptHandler != null) {
FileLogger.log(TAG, "SSH[$sessionId] no stored auth — prompting for password")
val result = promptHandler(host, username, null)
if (result == null) {
// User chose disconnect
updateSessionState(sessionId, SessionState.Disconnected("User cancelled auth", cleanExit = true))
cleanupDisconnectedSession(sessionId)
return@launch
}
entry.password = result.password.toCharArray()
if (result.remember && savedConnectionId > 0) {
credentialStore.savePassword(savedConnectionId, result.password)
}
auth = SSHAuth.Password(result.password)
}
}
// Server-driven auth: always attempt the connection first. If no credentials
// are stored (no key, no password), buildAuth returns SSHAuth.None and the
// cascade in SSHSession.authenticateAll falls through to keyboard-interactive,
// which prompts the user via authPromptHandler only when the server actually
// asks for it.
val auth = helper.buildAuth(keyId, savedConnectionId, String(password))
try {
// Log auth + jump info into SSHSession's debug buffer (visible in terminal on failure)
val authDesc = when (auth) {
@ -619,6 +598,16 @@ class TerminalService : Service() {
proxyClient = proxyClient
)
session.connect(config)
// Auth succeeded. If the user ticked "Remember password" in an
// auth prompt, SSHSession stashed it in pendingRememberPassword.
// Persist it now (only on success — failed auth never reaches here).
val remembered = session.pendingRememberPassword
if (remembered != null && savedConnectionId > 0) {
credentialStore.savePassword(savedConnectionId, remembered)
entry.password = remembered.toCharArray()
FileLogger.log(TAG, "SSH[$sessionId] saved prompted password for conn $savedConnectionId")
}
session.pendingRememberPassword = null
// Monitor jump host sessions — if any jump tunnel dies,
// force disconnect the dependent session immediately.
// Single parent job so all collectors cancel together on cleanup.

View file

@ -75,7 +75,6 @@ import com.roundingmobile.sshworkbench.terminal.KeyboardSettingsScreen
import com.roundingmobile.sshworkbench.terminal.SnippetPickerSheet
import com.roundingmobile.sshworkbench.terminal.AuthPromptDialog
import com.roundingmobile.sshworkbench.terminal.HostKeyDialog
import com.roundingmobile.sshworkbench.terminal.PasswordDialog
import com.roundingmobile.sshworkbench.terminal.TerminalDialogRequest
import com.roundingmobile.sshworkbench.terminal.TerminalService
import com.roundingmobile.sshworkbench.ui.navigation.Routes
@ -144,25 +143,29 @@ class MainActivity : AppCompatActivity() {
}
}
// Wire up keyboard-interactive auth dialog
// Wire up keyboard-interactive auth dialog — this is the single
// entry point for server-driven password prompts. Invoked only after
// the TCP connection, KEX, and host key verification succeed and the
// server actually requests keyboard-interactive auth (or the
// password fallback fires for servers that only speak "password").
svc.authPromptHandler = { prompt ->
suspendCancellableCoroutine { cont ->
mainViewModel.showDialog(TerminalDialogRequest.AuthPrompt(
prompt.instruction,
prompt.prompts.map { it.prompt to it.echo },
showRemember = true
) { result -> cont.resume(result?.responses) })
}
}
// Wire up password prompt dialog (no stored password / auth failed)
svc.passwordPromptHandler = { host, username, errorMessage ->
suspendCancellableCoroutine { cont ->
mainViewModel.showDialog(TerminalDialogRequest.PasswordPrompt(
host, username, errorMessage,
onConnect = { result -> cont.resume(result) },
onDisconnect = { cont.resume(null) }
))
) { result ->
// Thread the remember flag through to SSHSession so it
// can be persisted only if authentication actually succeeds.
cont.resume(
result?.let {
com.roundingmobile.ssh.AuthPromptResult(
responses = it.responses,
remember = it.remember
)
}
)
})
}
}
@ -1040,10 +1043,6 @@ class MainActivity : AppCompatActivity() {
request = request,
onDismiss = { mainViewModel.dismissDialog() }
)
is TerminalDialogRequest.PasswordPrompt -> PasswordDialog(
request = request,
onDismiss = { mainViewModel.dismissDialog() }
)
}
}

2165
docs/LEGACY_TEST_LAB.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -464,15 +464,7 @@ Tabbed bottom sheet (Keyboard / Quick Bar):
**`TerminalDialogs`** — All dialogs use `MaterialAlertDialogBuilder` with tinted icons in title bar (`setIcon()`) and dark terminal theme styling:
- `showHostKey()` — TOFU host key verification (new key: lock icon/PRIMARY; changed key: alert icon/WARNING)
- `showAuthPrompt()` — keyboard-interactive auth with dynamic prompts and "Remember password" checkbox
- `showPasswordDialog()` — password-only auth prompt with:
- Error message display for failed attempts
- Password field masked by default (toggle via END_ICON_PASSWORD_TOGGLE)
- IME_ACTION_DONE triggers connect automatically
- `SOFT_INPUT_ADJUST_PAN` prevents dialog squishing
- Explicit `requestFocus()` + `showSoftInput()` on password field
- "Remember password" checkbox — only saves to CredentialStore when checked
- Connect / Disconnect buttons
- `AuthPromptDialog` — keyboard-interactive auth with dynamic prompts and "Remember password" checkbox. This is the single entry point for all server-driven password prompts — there is no pre-connect password dialog.
- `showCleanExit()` — session ended dialog (close or stay)
- `showUrlActions()` — open/copy detected URL
- `showSaveBuffer()` — save terminal buffer to file
@ -524,8 +516,11 @@ Active SSH session.
**Auth cascade** (`authenticateAll`):
1. `publickey` via `SSHKeyLoader.load()` if key configured
2. `password` if provided
3. `keyboard-interactive` via `AuthPromptCallback` — throws `UserAuthException` when no callback is set (never silently succeeds without auth)
4. If all fail → `UserAuthException`
3. **Keyboard-interactive** — server-driven prompts via `AuthPromptCallback.onAuthPrompt` → returns `AuthPromptResult(responses, remember)`. Up to 3 attempts (client-side `MAX_PASSWORD_ATTEMPTS`); retry shows "Incorrect password — please try again" as instruction.
4. **Prompted-password fallback** — if keyboard-interactive was rejected outright (server never invoked the provider) but `allowedMethods` contains "password", prompts user via the same `AuthPromptCallback` and calls `client.authPassword()`. Up to 3 attempts. Handles servers that only advertise the "password" SSH method.
5. If all methods fail → `UserAuthException` → disconnect banner.
**Remember password**: `AuthPromptResult.remember` flag threaded through callback. On auth success, `SSHSession.pendingRememberPassword` is set. `TerminalService` reads it after `session.connect()` returns successfully and calls `credentialStore.savePassword()`. If auth fails, field is never consumed → wrong passwords are never persisted.
**Connection health**:
- Keepalive: 15s interval, 3 missed = 45s cutoff
@ -548,7 +543,11 @@ data class SSHConnectionConfig(
- `Password(password)` — redacts in `toString()`
- `KeyFile(path, passphrase?)`
- `KeyString(key, passphrase?)` — PEM content in memory
- `None` — will prompt via keyboard-interactive
- `None` — will prompt via keyboard-interactive or prompted-password fallback
### `AuthPromptResult` (data class)
- `responses: List<String>` — user's answers (one per prompt)
- `remember: Boolean` — whether user ticked "Remember password"
### `SessionState` (sealed class)
- `Idle` — not connected
@ -865,10 +864,12 @@ Same pattern: saved to `SessionEntry.customLabel`/`customThemeName` on user acti
- `isAuthExpired()` checks elapsed time since last successful auth
### SSH Authentication Cascade
Server-driven: the TCP connection and host key verification always happen first. Only after the server actually requests authentication is any password dialog shown to the user. If no credentials are stored, `buildAuth` returns `SSHAuth.None` and the cascade falls straight through to keyboard-interactive, which prompts via `AuthPromptCallback` when (and only when) the server asks.
1. **Public key** (if SSH key configured) — `KeyString` with PEM from CredentialStore
2. **Password** (if stored or provided) — via `SSHAuth.Password`
3. **Keyboard-interactive** — server-driven prompts via `AuthPromptCallback`, dialog shown to user with "Remember password" option. Skipped entirely when no callback is set (no empty-password fallback)
4. If all methods fail → `UserAuthException`password retry dialog
2. **Password** (if stored or provided in connection editor) — via `SSHAuth.Password`
3. **Keyboard-interactive** — server-driven prompts via `AuthPromptCallback`, dialog shown to user with "Remember password" option. Skipped entirely when no callback is set (no empty-password fallback).
4. If all methods fail → `UserAuthException`disconnect banner shown in terminal, user can Reconnect.
### Logging & Redaction
- `FileLogger` automatically redacts any string containing "password" or "passphrase" in log output

View file

@ -41,6 +41,8 @@
- ~~NumBlok toggle on mini numpad~~`KeyDefinition.numLabel`/`numAction` fields, `KeyAction.ToggleNumBlock`; mini last row has `Num 0 \` which becomes PC-keypad nav when toggled
- ~~Hardware keyboard auto-hide~~`Configuration.keyboard` detection; CKB + QB hidden by default when BT/USB keyboard connects; kebab Show/Hide toggles both as a pair in HW kb mode
- ~~Audit 2026-04-06~~`KeyboardPageView` smart-cast `!!` removal, `SSHKeyLoader` Ed25519 seed length require, `vault_crypto.cpp` plaintext `secure_zero` before `ReleaseByteArrayElements` in `nativeEncrypt`
- ~~Server-driven auth~~ — removed pre-connect password dialog; auth prompts only after TCP+KEX+host key; `AuthPromptResult(responses, remember)` in lib-ssh API; prompted-password fallback for servers that reject keyboard-interactive; 3-attempt retry (matching OpenSSH `NumberOfPasswordPrompts`); "Remember password" saves only on successful connect via `pendingRememberPassword`
- ~~Legacy test lab documentation~~`docs/LEGACY_TEST_LAB.md` (2165 lines): 56-system matrix, Dockerfiles, compose.yml, SIMH configs, systemd units, scripts, `LAB_ROOT` configurability, self-extracting doc
## Open

View file

@ -80,6 +80,20 @@ data class PromptEntry(
val echo: Boolean // false = password field, true = visible text
)
/**
* Result from an auth prompt dialog.
*
* @param responses the user's responses, one entry per prompt shown.
* @param remember whether the user ticked "Remember password". The responses
* should only actually be persisted if the corresponding authentication
* attempt succeeds callers must defer the save until after
* [SSHSession.connect] returns without throwing.
*/
data class AuthPromptResult(
val responses: List<String>,
val remember: Boolean = false
)
/**
* Callback interface for interactive authentication.
* The SSH session will call [onAuthPrompt] when the server requests user input.
@ -89,11 +103,11 @@ data class PromptEntry(
interface AuthPromptCallback {
/**
* Called when the server sends an authentication prompt.
* Must return responses (one per prompt) or null to cancel.
* Must return an [AuthPromptResult] or null to cancel.
* This is called on an IO thread implementations should use a mechanism
* to show UI and block until the user responds.
*/
suspend fun onAuthPrompt(prompt: AuthPrompt): List<String>?
suspend fun onAuthPrompt(prompt: AuthPrompt): AuthPromptResult?
/**
* Called when the server sends a banner message (informational).

View file

@ -96,6 +96,12 @@ class SSHSession {
private const val IDLE_SILENCE_PROBE_MS = 120_000L // probe after 2 min of total silence (no reads at all)
private const val RECENT_ACTIVITY_MS = 60_000L // "recent" = write in last 60s
private const val IDLE_LOG_INTERVAL = 20 // log every 20 cycles (5 min) when idle
/** Client-side cap on password prompts per connection. Matches
* OpenSSH's NumberOfPasswordPrompts default. The server's MaxAuthTries
* is a hard upper bound and must be this value. */
private const val MAX_PASSWORD_ATTEMPTS = 3
/** Shown as the dialog instruction on retry after a wrong password. */
private const val INCORRECT_PASSWORD_MESSAGE = "Incorrect password — please try again"
/** Serializes SSHJ trace capture across all SSHSession instances.
* System.setErr/System.setProperty are JVM-global concurrent connections
@ -123,6 +129,17 @@ class SSHSession {
/** Callback for TOFU host key verification. If null, falls back to accept-all (insecure). */
var hostKeyVerifyCallback: HostKeyVerifyCallback? = null
/**
* Password the user typed with "Remember" ticked, waiting to be persisted.
* Set inside the auth cascade only while a prompt is in flight; the caller
* (TerminalService) reads this field after [connect] returns successfully
* and copies it to CredentialStore, then clears it. If [connect] throws,
* the field is not consumed and nothing is saved this is what ties
* "remember password" to "login actually succeeded".
*/
@Volatile
var pendingRememberPassword: String? = null
private fun log(msg: String) {
logger?.invoke("SSHSession", msg)
}
@ -336,8 +353,19 @@ class SSHSession {
/**
* Attempt keyboard-interactive authentication.
* Uses the authPromptCallback to show prompts to the user via a blocking bridge.
*
* @param onProviderInvoked called once if the server actually asks us for a
* response (via ChallengeResponseProvider.init or getResponse). Lets the
* caller distinguish "server rejected keyboard-interactive outright" from
* "server prompted us but the password was wrong", so the caller can
* decide whether to fall back to plain password auth.
*/
private suspend fun authenticateInteractive(client: SSHClient, username: String) {
private suspend fun authenticateInteractive(
client: SSHClient,
username: String,
errorMessage: String? = null,
onProviderInvoked: () -> Unit = {}
) {
val callback = authPromptCallback
if (callback == null) {
log("authenticateInteractive: no callback set, skipping keyboard-interactive auth")
@ -349,6 +377,10 @@ class SSHSession {
log("authenticateInteractive: starting keyboard-interactive auth")
// Clear any previous pending remember — we only want to persist the
// password from THIS attempt if it succeeds.
pendingRememberPassword = null
// Bridge between SSHJ's synchronous ChallengeResponseProvider and our suspend callback.
// SSHJ calls getResponse() on its transport thread — we use a CompletableFuture to
// block that thread while showing a dialog on the UI thread.
@ -360,6 +392,7 @@ class SSHSession {
name: String?,
instruction: String?
) {
onProviderInvoked()
log("authenticateInteractive: init name='$name' instruction='$instruction'")
if (!instruction.isNullOrBlank()) {
callback.onBanner(instruction)
@ -367,26 +400,34 @@ class SSHSession {
}
override fun getResponse(prompt: String?, echo: Boolean): CharArray {
onProviderInvoked()
val promptText = prompt ?: "Password: "
log("authenticateInteractive: getResponse prompt='$promptText' echo=$echo")
// Use a CompletableFuture to bridge sync→async
val future = java.util.concurrent.CompletableFuture<String?>()
val future = java.util.concurrent.CompletableFuture<AuthPromptResult?>()
// Launch a coroutine to call our suspend callback
scope.launch {
val authPrompt = AuthPrompt(
name = "",
instruction = "",
instruction = errorMessage ?: "",
prompts = listOf(PromptEntry(promptText, echo))
)
val responses = callback.onAuthPrompt(authPrompt)
future.complete(responses?.firstOrNull())
val result = callback.onAuthPrompt(authPrompt)
future.complete(result)
}
val response = future.get(120, java.util.concurrent.TimeUnit.SECONDS)
return response?.toCharArray() ?: throw net.schmizz.sshj.userauth.UserAuthException(
net.schmizz.sshj.common.DisconnectReason.AUTH_CANCELLED_BY_USER
)
val result = future.get(120, java.util.concurrent.TimeUnit.SECONDS)
val response = result?.responses?.firstOrNull()
?: throw net.schmizz.sshj.userauth.UserAuthException(
net.schmizz.sshj.common.DisconnectReason.AUTH_CANCELLED_BY_USER
)
// Stash for post-success save. If auth fails, connect() throws
// and TerminalService never reads this — so nothing gets persisted.
if (result.remember && !echo) {
pendingRememberPassword = response
}
return response.toCharArray()
}
override fun shouldRetry(): Boolean = false
@ -396,16 +437,67 @@ class SSHSession {
val method = net.schmizz.sshj.userauth.method.AuthKeyboardInteractive(provider)
client.auth(username, method)
} catch (e: net.schmizz.sshj.userauth.UserAuthException) {
// Clear pending remember on failure — the password was wrong.
pendingRememberPassword = null
log("authenticateInteractive: keyboard-interactive failed: ${e.message}")
throw e
}
}
/**
* Prompt the user for a password via authPromptCallback and try plain
* password auth. Used as a fallback when keyboard-interactive is not offered
* by the server but "password" is in allowedMethods.
*
* Reuses the same dialog infrastructure as keyboard-interactive so the user
* sees an identical prompt UX regardless of which SSH method the server
* actually wants.
*
* @param errorMessage shown as the dialog's instruction text on retries
* ("Incorrect password — please try again"). Null on first attempt.
*/
private suspend fun authenticatePromptedPassword(
client: SSHClient,
username: String,
errorMessage: String? = null
) {
val callback = authPromptCallback
?: throw net.schmizz.sshj.userauth.UserAuthException(
net.schmizz.sshj.common.DisconnectReason.NO_MORE_AUTH_METHODS_AVAILABLE,
"password (prompted): no prompt callback configured"
)
log("authenticatePromptedPassword: asking user for password (err=${errorMessage != null})")
pendingRememberPassword = null
val prompt = AuthPrompt(
name = "",
instruction = errorMessage ?: "",
prompts = listOf(PromptEntry("Password: ", false))
)
val result = callback.onAuthPrompt(prompt)
val pw = result?.responses?.firstOrNull()
?: throw net.schmizz.sshj.userauth.UserAuthException(
net.schmizz.sshj.common.DisconnectReason.AUTH_CANCELLED_BY_USER,
"password (prompted): user cancelled"
)
try {
client.authPassword(username, pw)
// Auth accepted — stash for TerminalService to persist if requested.
if (result.remember) {
pendingRememberPassword = pw
}
} catch (e: net.schmizz.sshj.userauth.UserAuthException) {
pendingRememberPassword = null
throw e
}
}
/**
* Try authentication methods in priority order until one succeeds:
* 1. publickey (if key configured)
* 2. password (if password provided)
* 3. keyboard-interactive (prompt user)
* 4. prompted-password fallback (for servers that only advertise "password"
* and reject keyboard-interactive outright)
*
* Each failure is logged to the debug buffer. If all fail, throws with
* a summary of every method attempted.
@ -460,17 +552,70 @@ class SSHSession {
}
}
// --- 3. Keyboard-interactive (prompt user) ---
try {
debugLog("Trying keyboard-interactive auth")
log("auth: trying keyboard-interactive")
authenticateInteractive(client, username)
debugLog("keyboard-interactive auth succeeded")
return
} catch (e: net.schmizz.sshj.userauth.UserAuthException) {
tried.add("keyboard-interactive: ${e.message}")
debugLog("keyboard-interactive failed: ${e.message}")
log("auth: keyboard-interactive failed: ${e.message}")
// --- 3. Keyboard-interactive (prompt user, up to MAX_PASSWORD_ATTEMPTS) ---
// Standard SSH clients retry 3 times before giving up. The server's
// MaxAuthTries is a hard upper bound but is usually ≥ our retry count.
var kbProviderInvoked = false
var kbErrorForNextAttempt: String? = null
var kbAttempts = 0
while (kbAttempts < MAX_PASSWORD_ATTEMPTS) {
try {
debugLog("Trying keyboard-interactive auth (attempt ${kbAttempts + 1}/$MAX_PASSWORD_ATTEMPTS)")
log("auth: trying keyboard-interactive (attempt ${kbAttempts + 1})")
authenticateInteractive(
client, username, kbErrorForNextAttempt
) { kbProviderInvoked = true }
debugLog("keyboard-interactive auth succeeded")
return
} catch (e: net.schmizz.sshj.userauth.UserAuthException) {
kbAttempts++
tried.add("keyboard-interactive #$kbAttempts: ${e.message}")
debugLog("keyboard-interactive #$kbAttempts failed: ${e.message}")
log("auth: keyboard-interactive #$kbAttempts failed: ${e.message}")
if (!kbProviderInvoked) {
// Server never engaged us — method isn't supported. Don't
// keep retrying, fall through to the password fallback.
break
}
if (kbAttempts >= MAX_PASSWORD_ATTEMPTS) break
kbErrorForNextAttempt = INCORRECT_PASSWORD_MESSAGE
}
}
// --- 4. Prompted-password fallback (up to MAX_PASSWORD_ATTEMPTS) ---
// Only fires when the server never invoked our keyboard-interactive
// provider (meaning it rejected the method outright) AND the server's
// allowedMethods list still contains "password". This is how we handle
// servers that only accept the plain "password" method — we prompt the
// user *after* the server has said "I want a password", never before.
if (!kbProviderInvoked && password.isNullOrEmpty()) {
val allowed: List<String> = try {
client.userAuth?.allowedMethods?.toList() ?: emptyList()
} catch (e: Exception) {
log("auth: could not read allowedMethods: ${e.message}")
emptyList()
}
log("auth: server allowedMethods = $allowed")
if ("password" in allowed) {
var pwErrorForNextAttempt: String? = null
var pwAttempts = 0
while (pwAttempts < MAX_PASSWORD_ATTEMPTS) {
try {
debugLog("Trying prompted-password auth (attempt ${pwAttempts + 1}/$MAX_PASSWORD_ATTEMPTS)")
log("auth: trying prompted-password (attempt ${pwAttempts + 1})")
authenticatePromptedPassword(client, username, pwErrorForNextAttempt)
debugLog("prompted-password auth succeeded")
return
} catch (e: net.schmizz.sshj.userauth.UserAuthException) {
pwAttempts++
tried.add("password (prompted) #$pwAttempts: ${e.message}")
debugLog("prompted-password #$pwAttempts failed: ${e.message}")
log("auth: prompted-password #$pwAttempts failed: ${e.message}")
if (pwAttempts >= MAX_PASSWORD_ATTEMPTS) break
pwErrorForNextAttempt = INCORRECT_PASSWORD_MESSAGE
}
}
}
}
// --- All exhausted ---