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:
parent
e243b8e1e5
commit
7f4aa15830
11 changed files with 2405 additions and 192 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
2165
docs/LEGACY_TEST_LAB.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue