Mouse tap-only, ProxyJump probe fix, Ed25519 key loading, jump tunnel monitor
Mouse mode: only taps forwarded as clicks, scroll/select/fling always work. Active pane zIndex ensures touch events reach correct session. SSHKeyLoader: auto-detect key format, Ed25519 PKCS#8 via net.i2p.crypto.eddsa. ProxyJump probe: tunneled connections trust isConnected (no raw socket). Jump monitor: watches tunnel state, force-disconnects on tunnel death. Copy Private Key works without biometric lock enabled. KeyLoadingTest: 9 tests covering all key formats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9c980bbea7
commit
2fd8308056
11 changed files with 374 additions and 99 deletions
|
|
@ -120,6 +120,7 @@ class TerminalService : Service() {
|
|||
var onBufferReplaced: ((ScreenBuffer) -> Unit)? = null,
|
||||
var outputJob: Job? = null,
|
||||
var stateJob: Job? = null,
|
||||
var jumpMonitorJob: Job? = null,
|
||||
/** Per-session font size (zoom level). 0 = use default. Survives Activity destruction. */
|
||||
var fontSizeSp: Float = 0f,
|
||||
/** Per-session label override (null = use connection name). Survives Activity destruction. */
|
||||
|
|
@ -588,6 +589,31 @@ class TerminalService : Service() {
|
|||
proxyClient = proxyClient
|
||||
)
|
||||
session.connect(config)
|
||||
// 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.
|
||||
if (entry.jumpHostSessions.isNotEmpty()) {
|
||||
entry.jumpMonitorJob = serviceScope.launch {
|
||||
for (jumpSession in entry.jumpHostSessions) {
|
||||
launch {
|
||||
jumpSession.state.collect { jumpState ->
|
||||
if (jumpState is SessionState.Disconnected || jumpState is SessionState.Error) {
|
||||
val mainState = session.state.value
|
||||
if (mainState is SessionState.Connected) {
|
||||
val reason = when (jumpState) {
|
||||
is SessionState.Disconnected -> jumpState.reason
|
||||
is SessionState.Error -> jumpState.message
|
||||
else -> "unknown"
|
||||
}
|
||||
FileLogger.log(TAG, "SSH[$sessionId] jump tunnel died: $reason — forcing disconnect")
|
||||
session.forceDisconnect(getString(R.string.disconnect_jump_host_lost))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
FileLogger.log(TAG, "connectSSH[$sessionId] jump chain failed: ${e.message}")
|
||||
updateSessionState(sessionId, SessionState.Error("Jump host failed: ${e.message}", e))
|
||||
|
|
@ -1050,6 +1076,7 @@ class TerminalService : Service() {
|
|||
entry.password = null
|
||||
entry.outputJob?.cancel()
|
||||
entry.stateJob?.cancel()
|
||||
entry.jumpMonitorJob?.cancel()
|
||||
entry.sshSession?.disconnect()
|
||||
entry.localShellSession?.stop()
|
||||
entry.telnetSession?.disconnect()
|
||||
|
|
@ -1197,6 +1224,7 @@ class TerminalService : Service() {
|
|||
val savedFontSize = entry.fontSizeSp
|
||||
entry.outputJob?.cancel()
|
||||
entry.stateJob?.cancel()
|
||||
entry.jumpMonitorJob?.cancel()
|
||||
// Disconnect old SSH session without removing from sessions map —
|
||||
// connectSSHInternal will replace the entry. Keeping it in the map ensures
|
||||
// the service never sees sessions.isEmpty() during the reconnect window.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
|
|
@ -367,6 +368,9 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
// Session surfaces — terminal panes only (no keyboard per pane)
|
||||
// Active session gets zIndex(1f) so it receives touch events
|
||||
// regardless of iteration order (INVISIBLE AndroidViews in Compose
|
||||
// can still intercept pointer events from composables below them).
|
||||
Box(Modifier.weight(1f)) {
|
||||
for (sid in tabOrder) {
|
||||
val isVisible = showTerminal && appState.activeSessionId == sid
|
||||
|
|
@ -376,7 +380,8 @@ class MainActivity : AppCompatActivity() {
|
|||
val info = sftpTabs[sid]
|
||||
if (info != null) {
|
||||
// Always composed (preserves SAF launchers across picker lifecycle)
|
||||
Box(modifier = if (isVisible) Modifier.fillMaxSize() else Modifier.fillMaxSize().alpha(0f)) {
|
||||
val sftpMod = if (isVisible) Modifier.fillMaxSize().zIndex(1f) else Modifier.fillMaxSize().alpha(0f)
|
||||
Box(modifier = sftpMod) {
|
||||
SftpScreen(
|
||||
connectionId = info.connectionId,
|
||||
sftpSessionId = info.sftpSessionId,
|
||||
|
|
@ -423,7 +428,8 @@ class MainActivity : AppCompatActivity() {
|
|||
mainViewModel.setSessionFontSize(sid, fontSize)
|
||||
},
|
||||
onShowKeyboard = { ckbHidden = false },
|
||||
themeOverride = sessionThemes[sid] ?: ""
|
||||
themeOverride = sessionThemes[sid] ?: "",
|
||||
modifier = if (isVisible) Modifier.zIndex(1f) else Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,23 +173,23 @@ fun KeyManagerScreen(
|
|||
key = key,
|
||||
onDelete = { keyToDelete = key },
|
||||
onCopyPrivateKey = {
|
||||
if (biometricAuth != null && biometricAuth.isEnabled() && activity != null) {
|
||||
if (activity == null) return@KeyItem
|
||||
val doCopy = {
|
||||
val privateKey = viewModel.getPrivateKey(key.id)
|
||||
if (privateKey != null) {
|
||||
val cm = activity.getSystemService(android.content.ClipboardManager::class.java)
|
||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("SSH Private Key", privateKey))
|
||||
android.widget.Toast.makeText(activity, activity.getString(R.string.key_manager_private_copied), android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
if (biometricAuth != null && biometricAuth.isEnabled()) {
|
||||
biometricAuth.promptForSensitiveAction(
|
||||
activity = activity,
|
||||
reason = activity.getString(R.string.key_manager_export_reason),
|
||||
onSuccess = {
|
||||
val privateKey = viewModel.getPrivateKey(key.id)
|
||||
if (privateKey != null) {
|
||||
val cm = activity.getSystemService(android.content.ClipboardManager::class.java)
|
||||
cm.setPrimaryClip(android.content.ClipData.newPlainText("SSH Private Key", privateKey))
|
||||
android.widget.Toast.makeText(activity, activity.getString(R.string.key_manager_private_copied), android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
onSuccess = doCopy
|
||||
)
|
||||
} else if (activity != null) {
|
||||
android.widget.Toast.makeText(activity,
|
||||
activity.getString(R.string.key_manager_auth_required),
|
||||
android.widget.Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
doCopy()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ Feature flags for free/pro tiers. Two implementations in `free/` and `pro/` sour
|
|||
|
||||
#### `MainActivity`
|
||||
Single Activity host for the entire app. Three-layer Box architecture:
|
||||
- **Layer 1**: Terminal surfaces (`TerminalPane` per session) + SFTP tabs (`SftpScreen`) + `SessionTabBar` — always in tree, visibility toggled via `alpha(0f)`. Iterates `tabOrder`, renders `TerminalPane` or `SftpScreen` based on `tabTypes`. `key(sid)` for stable Compose identity, `View.INVISIBLE` preserves SurfaceView surfaces.
|
||||
- **Layer 1**: Terminal surfaces (`TerminalPane` per session) + SFTP tabs (`SftpScreen`) + `SessionTabBar` — always in tree, visibility toggled via `alpha(0f)`. Iterates `tabOrder`, renders `TerminalPane` or `SftpScreen` based on `tabTypes`. `key(sid)` for stable Compose identity, `View.INVISIBLE` preserves SurfaceView surfaces. Active session gets `zIndex(1f)` to ensure it receives touch events regardless of Compose stacking order (INVISIBLE `AndroidView` wrappers can intercept pointer events).
|
||||
- **Layer 2**: NavHost (AnimatedVisibility) — ConnectionListScreen, EditConnectionScreen, SettingsScreen, KeyManagerScreen
|
||||
- Binds to TerminalService, delegates all state management to MainViewModel
|
||||
|
||||
|
|
@ -510,8 +510,14 @@ Active SSH session.
|
|||
- `disconnect()` — graceful close
|
||||
- `debugLogBuffer` — synchronized compound operations (check-then-add) for thread safety
|
||||
|
||||
**Key loading** — delegated to `SSHKeyLoader`:
|
||||
- Auto-detects format from PEM header: PKCS#8, OpenSSH v1, legacy PEM, PuTTY PPK
|
||||
- Ed25519 PKCS#8 (OID 1.3.101.112): parsed via BC ASN.1, seed extracted, `net.i2p.crypto.eddsa` keys built (SSHJ's wire encoder requires these, not BC key types)
|
||||
- RSA/ECDSA PKCS#8: delegated to SSHJ's `PKCS8KeyFile`
|
||||
- OpenSSH v1: `OpenSSHKeyV1KeyFile`, legacy PEM: `OpenSSHKeyFile`, PuTTY: `PuTTYKeyFile`
|
||||
|
||||
**Auth cascade** (`authenticateAll`):
|
||||
1. `publickey` (KeyString/KeyFile) if provided
|
||||
1. `publickey` via `SSHKeyLoader.load()` if key configured
|
||||
2. `password` if provided
|
||||
3. `keyboard-interactive` via `AuthPromptCallback` — skipped entirely when no callback is set (no empty-password fallback)
|
||||
4. If all fail → `UserAuthException`
|
||||
|
|
@ -519,8 +525,9 @@ Active SSH session.
|
|||
**Connection health**:
|
||||
- Keepalive: 15s interval, 3 missed = 45s cutoff
|
||||
- Read silence monitor: detects zombie TCP (write succeeds but no read)
|
||||
- TCP probe: `sendUrgentData(0)` to verify socket
|
||||
- TCP probe: `sendUrgentData(0)` for direct connections; tunneled connections (`connectVia`) have no raw socket — probe trusts `client.isConnected` and SSHJ's KeepAliveRunner
|
||||
- Transport `DisconnectListener`: catches SSHJ reader thread crashes (e.g., WiFi off)
|
||||
- **Jump session monitor**: coroutine watches each jump session's `state` flow — if a jump tunnel dies, the dependent session is force-disconnected immediately (SSHJ's `DirectConnection` channel doesn't propagate errors to the inner transport)
|
||||
|
||||
### `SSHConnectionConfig`
|
||||
```kotlin
|
||||
|
|
@ -641,9 +648,10 @@ SurfaceView with dedicated render thread.
|
|||
- **Snap-to-content on drop**: when a handle is dropped on trailing whitespace, it snaps to the nearest content. `snapStartToContent()` snaps forward, `snapEndToContent()` snaps backward. Uses `firstNonSpaceCol()`/`lastNonSpaceCol()` for whitespace detection (handles all whitespace types, not just space). Snapping only occurs on `ACTION_UP`, not during drag.
|
||||
|
||||
**Mouse reporting** (`MouseReporter`):
|
||||
- Handles X10/Normal/Button-Event/Any-Event mouse tracking
|
||||
- SGR and URXVT encoding
|
||||
- Extracted to `MouseReporter.kt` — receives `onTerminalInput` callback
|
||||
- Only **taps** are forwarded as mouse clicks when mouse mode is active
|
||||
- Scroll, long-press (select), double-tap, and fling always bypass mouse mode — scrollback and text selection always work
|
||||
- SGR, URXVT, and X10 coordinate encoding
|
||||
- Extracted to `MouseReporter.kt` — `sendClick()` sends press+release at pixel coordinates
|
||||
|
||||
**URL detection** (`TerminalUrlDetector`):
|
||||
- Regex matching on visible viewport
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ android {
|
|||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
api(libs.sshj)
|
||||
implementation("net.i2p.crypto:eddsa:0.3.0") // SSHJ's Ed25519 key types
|
||||
implementation(libs.bouncycastle.prov)
|
||||
implementation(libs.bouncycastle.pkix)
|
||||
implementation(libs.slf4j.simple)
|
||||
|
|
|
|||
119
lib-ssh/src/main/java/com/roundingmobile/ssh/SSHKeyLoader.kt
Normal file
119
lib-ssh/src/main/java/com/roundingmobile/ssh/SSHKeyLoader.kt
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
package com.roundingmobile.ssh
|
||||
|
||||
import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile
|
||||
import net.i2p.crypto.eddsa.EdDSAPrivateKey
|
||||
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
|
||||
import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
|
||||
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
|
||||
import net.schmizz.sshj.common.KeyType
|
||||
import net.schmizz.sshj.userauth.keyprovider.KeyProvider
|
||||
import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile
|
||||
import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile
|
||||
import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile
|
||||
import net.schmizz.sshj.userauth.password.PasswordFinder
|
||||
import org.bouncycastle.asn1.ASN1OctetString
|
||||
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo
|
||||
import java.io.StringReader
|
||||
import java.security.PrivateKey
|
||||
import java.security.PublicKey
|
||||
|
||||
/**
|
||||
* Loads SSH private keys from PEM strings into SSHJ [KeyProvider] instances.
|
||||
*
|
||||
* Handles all formats:
|
||||
* - PKCS#8 PEM (`-----BEGIN PRIVATE KEY-----`) — our KeyGenerator output
|
||||
* - OpenSSH v1 (`-----BEGIN OPENSSH PRIVATE KEY-----`) — ssh-keygen default since 7.8
|
||||
* - Legacy PEM (`-----BEGIN RSA/DSA/EC PRIVATE KEY-----`) — older ssh-keygen, `-m PEM`
|
||||
* - PuTTY PPK (`PuTTY-User-Key-File-`) — PuTTYgen
|
||||
*
|
||||
* Ed25519 PKCS#8 keys are handled specially: the 32-byte seed is extracted via
|
||||
* BouncyCastle ASN.1, then [net.i2p.crypto.eddsa] keys are created (the type
|
||||
* SSHJ's wire encoder expects). Other PKCS#8 keys (RSA, ECDSA) go through
|
||||
* SSHJ's built-in [PKCS8KeyFile].
|
||||
*/
|
||||
object SSHKeyLoader {
|
||||
|
||||
private const val ED25519_OID = "1.3.101.112"
|
||||
|
||||
/**
|
||||
* Load a PEM-encoded private key string into an SSHJ [KeyProvider].
|
||||
* Auto-detects the format from the PEM header.
|
||||
*/
|
||||
fun load(pem: String, pw: PasswordFinder? = null): KeyProvider {
|
||||
val trimmed = pem.trimStart()
|
||||
return when {
|
||||
trimmed.startsWith("-----BEGIN PRIVATE KEY-----") -> loadPkcs8(pem, pw)
|
||||
trimmed.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----") -> loadOpenSshV1(pem, pw)
|
||||
trimmed.startsWith("PuTTY-User-Key-File-") -> loadPutty(pem, pw)
|
||||
else -> loadLegacyPem(pem, pw)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a PKCS#8 PEM key. Ed25519 keys are parsed via BC ASN.1 into
|
||||
* [net.i2p.crypto.eddsa] types; other algorithms use SSHJ's [PKCS8KeyFile].
|
||||
*/
|
||||
fun loadPkcs8(pem: String, pw: PasswordFinder? = null): KeyProvider {
|
||||
val b64 = pem.lines()
|
||||
.filter { !it.startsWith("-----") && it.isNotBlank() }
|
||||
.joinToString("")
|
||||
val pkcs8Bytes = java.util.Base64.getDecoder().decode(b64)
|
||||
val info = PrivateKeyInfo.getInstance(pkcs8Bytes)
|
||||
val oid = info.privateKeyAlgorithm.algorithm.id
|
||||
|
||||
if (oid == ED25519_OID) {
|
||||
return loadEd25519FromPkcs8(info)
|
||||
}
|
||||
|
||||
// RSA, ECDSA, DSA — SSHJ's built-in handler
|
||||
val kf = PKCS8KeyFile()
|
||||
kf.init(StringReader(pem), pw)
|
||||
return kf
|
||||
}
|
||||
|
||||
/** Load an OpenSSH v1 format key (`-----BEGIN OPENSSH PRIVATE KEY-----`). */
|
||||
fun loadOpenSshV1(pem: String, pw: PasswordFinder? = null): KeyProvider {
|
||||
val kf = OpenSSHKeyV1KeyFile()
|
||||
kf.init(StringReader(pem), pw)
|
||||
return kf
|
||||
}
|
||||
|
||||
/** Load a legacy PEM key (`-----BEGIN RSA/DSA/EC PRIVATE KEY-----`). */
|
||||
fun loadLegacyPem(pem: String, pw: PasswordFinder? = null): KeyProvider {
|
||||
val kf = OpenSSHKeyFile()
|
||||
kf.init(StringReader(pem), null, pw)
|
||||
return kf
|
||||
}
|
||||
|
||||
/** Load a PuTTY PPK format key. */
|
||||
fun loadPutty(pem: String, pw: PasswordFinder? = null): KeyProvider {
|
||||
val kf = PuTTYKeyFile()
|
||||
kf.init(StringReader(pem), pw)
|
||||
return kf
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract 32-byte Ed25519 seed from PKCS#8 [PrivateKeyInfo] and build
|
||||
* [net.i2p.crypto.eddsa] key objects that SSHJ can wire-encode.
|
||||
*/
|
||||
private fun loadEd25519FromPkcs8(info: PrivateKeyInfo): KeyProvider {
|
||||
val seed = ASN1OctetString.getInstance(info.parsePrivateKey()).octets
|
||||
val edSpec = EdDSANamedCurveTable.getByName("Ed25519")
|
||||
val privSpec = EdDSAPrivateKeySpec(seed, edSpec)
|
||||
val privKey = EdDSAPrivateKey(privSpec)
|
||||
val pubSpec = EdDSAPublicKeySpec(privSpec.a, edSpec)
|
||||
val pubKey = com.hierynomus.sshj.signature.Ed25519PublicKey(pubSpec)
|
||||
return SimpleKeyProvider(privKey, pubKey, KeyType.fromKey(pubKey))
|
||||
}
|
||||
|
||||
/** Minimal [KeyProvider] wrapping a pre-built key pair. */
|
||||
private class SimpleKeyProvider(
|
||||
private val priv: PrivateKey,
|
||||
private val pub: PublicKey,
|
||||
private val type: KeyType
|
||||
) : KeyProvider {
|
||||
override fun getPrivate() = priv
|
||||
override fun getPublic() = pub
|
||||
override fun getType() = type
|
||||
}
|
||||
}
|
||||
|
|
@ -99,8 +99,9 @@ class SSHSession {
|
|||
|
||||
init {
|
||||
// Register BouncyCastle as LAST provider (fallback only).
|
||||
// Android's Conscrypt handles most crypto natively (fast, already loaded).
|
||||
// BC is only needed for X25519 key exchange and Ed25519.
|
||||
// Android's Conscrypt handles most crypto natively (fast).
|
||||
// BC is needed for X25519 key exchange. Ed25519 user keys are handled
|
||||
// via net.i2p.crypto.eddsa (SSHJ's bundled EdDSA library).
|
||||
Security.removeProvider("BC")
|
||||
Security.addProvider(BouncyCastleProvider())
|
||||
// Tell SSHJ not to try its own BC registration (we already did it)
|
||||
|
|
@ -395,12 +396,10 @@ class SSHSession {
|
|||
else client.loadKeys(auth.path)
|
||||
}
|
||||
is SSHAuth.KeyString -> {
|
||||
val kf = net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile()
|
||||
val pw = if (auth.passphrase != null)
|
||||
net.schmizz.sshj.userauth.password.PasswordUtils.createOneOff(auth.passphrase.toCharArray())
|
||||
else null
|
||||
kf.init(java.io.StringReader(auth.key), null, pw)
|
||||
kf
|
||||
SSHKeyLoader.load(auth.key, pw)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
|
@ -572,10 +571,17 @@ class SSHSession {
|
|||
*/
|
||||
private fun probeConnection(client: SSHClient): Boolean {
|
||||
return try {
|
||||
if (!client.isConnected) {
|
||||
log("probe: client reports not connected")
|
||||
return false
|
||||
}
|
||||
val socket = client.socket
|
||||
if (socket == null) {
|
||||
log("probe: socket is null")
|
||||
return false
|
||||
// Tunneled connection (connectVia) — no raw TCP socket.
|
||||
// SSHJ's KeepAliveRunner handles liveness detection via the tunnel.
|
||||
// isConnected already confirmed the transport is alive.
|
||||
log("probe: tunneled connection, relying on keepalive")
|
||||
return true
|
||||
}
|
||||
if (socket.isClosed || socket.isInputShutdown || socket.isOutputShutdown) {
|
||||
log("probe: socket dead (closed=${socket.isClosed} inShut=${socket.isInputShutdown} outShut=${socket.isOutputShutdown})")
|
||||
|
|
@ -597,7 +603,7 @@ class SSHSession {
|
|||
/**
|
||||
* Force disconnect and mark session as dead with the given reason.
|
||||
*/
|
||||
private fun forceDisconnect(reason: String) {
|
||||
fun forceDisconnect(reason: String) {
|
||||
if (_state.value is SessionState.Connected) {
|
||||
_state.value = SessionState.Disconnected(reason)
|
||||
}
|
||||
|
|
|
|||
155
lib-ssh/src/test/java/com/roundingmobile/ssh/KeyLoadingTest.kt
Normal file
155
lib-ssh/src/test/java/com/roundingmobile/ssh/KeyLoadingTest.kt
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package com.roundingmobile.ssh
|
||||
|
||||
import net.schmizz.sshj.common.KeyType
|
||||
import net.schmizz.sshj.common.SecurityUtils
|
||||
import net.schmizz.sshj.userauth.password.PasswordUtils
|
||||
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
|
||||
import org.bouncycastle.crypto.generators.RSAKeyPairGenerator
|
||||
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.RSAKeyGenerationParameters
|
||||
import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters
|
||||
import org.bouncycastle.crypto.util.PrivateKeyInfoFactory
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import org.bouncycastle.util.io.pem.PemObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.io.StringWriter
|
||||
import java.math.BigInteger
|
||||
import java.security.SecureRandom
|
||||
import java.security.Security
|
||||
|
||||
class KeyLoadingTest {
|
||||
|
||||
companion object {
|
||||
private lateinit var ed25519Pem: String
|
||||
private lateinit var rsaPem: String
|
||||
private var sshKeygen = false
|
||||
private var opensshEd25519: String? = null
|
||||
private var opensshRsa: String? = null
|
||||
private var opensshEcdsa256: String? = null
|
||||
private var legacyPemRsa: String? = null
|
||||
private var legacyPemEcdsa: String? = null
|
||||
private var opensshEd25519Pass: String? = null
|
||||
private var opensshRsaPass: String? = null
|
||||
private val tmpDir = File(System.getProperty("java.io.tmpdir"), "sshkeytest")
|
||||
|
||||
private fun keygen(name: String, vararg args: String, passphrase: String = ""): String? {
|
||||
val f = File(tmpDir, name)
|
||||
if (f.exists()) return f.readText()
|
||||
return try {
|
||||
val cmd = mutableListOf("ssh-keygen", "-q", "-f", f.absolutePath, "-N", passphrase)
|
||||
cmd.addAll(args)
|
||||
val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
|
||||
p.inputStream.readBytes()
|
||||
if (p.waitFor() == 0) f.readText() else null
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun bcPem(key: org.bouncycastle.crypto.params.AsymmetricKeyParameter): String {
|
||||
val info = PrivateKeyInfoFactory.createPrivateKeyInfo(key)
|
||||
val sw = StringWriter()
|
||||
org.bouncycastle.openssl.jcajce.JcaPEMWriter(sw).use {
|
||||
it.writeObject(PemObject("PRIVATE KEY", info.encoded))
|
||||
}
|
||||
return sw.toString()
|
||||
}
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setup() {
|
||||
Security.removeProvider("BC")
|
||||
Security.addProvider(BouncyCastleProvider())
|
||||
SecurityUtils.setRegisterBouncyCastle(false)
|
||||
|
||||
val edGen = Ed25519KeyPairGenerator()
|
||||
edGen.init(Ed25519KeyGenerationParameters(SecureRandom()))
|
||||
ed25519Pem = bcPem(edGen.generateKeyPair().private as Ed25519PrivateKeyParameters)
|
||||
|
||||
val rsaGen = RSAKeyPairGenerator()
|
||||
rsaGen.init(RSAKeyGenerationParameters(BigInteger.valueOf(65537), SecureRandom(), 2048, 80))
|
||||
rsaPem = bcPem(rsaGen.generateKeyPair().private as RSAPrivateCrtKeyParameters)
|
||||
|
||||
// ssh-keygen keys (saved to disk, reused across runs)
|
||||
tmpDir.mkdirs()
|
||||
sshKeygen = try { Runtime.getRuntime().exec(arrayOf("ssh-keygen", "--help")).waitFor(); true } catch (_: Exception) { false }
|
||||
if (sshKeygen) {
|
||||
opensshEd25519 = keygen("ed25519_v1", "-t", "ed25519")
|
||||
opensshRsa = keygen("rsa_v1", "-t", "rsa", "-b", "2048")
|
||||
opensshEcdsa256 = keygen("ecdsa256_v1", "-t", "ecdsa", "-b", "256")
|
||||
legacyPemRsa = keygen("rsa_legacy", "-t", "rsa", "-b", "2048", "-m", "PEM")
|
||||
legacyPemEcdsa = keygen("ecdsa_legacy", "-t", "ecdsa", "-b", "256", "-m", "PEM")
|
||||
opensshEd25519Pass = keygen("ed25519_pass", "-t", "ed25519", passphrase = "test123")
|
||||
opensshRsaPass = keygen("rsa_pass", "-t", "rsa", "-b", "2048", passphrase = "test123")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pkcs8 Ed25519 loads with correct key type`() {
|
||||
val kp = SSHKeyLoader.loadPkcs8(ed25519Pem)
|
||||
assertEquals(KeyType.ED25519, kp.type)
|
||||
assertNotNull(kp.private)
|
||||
assertNotNull(kp.public)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pkcs8 RSA-2048 loads with correct key type`() {
|
||||
val kp = SSHKeyLoader.loadPkcs8(rsaPem)
|
||||
assertEquals(KeyType.RSA, kp.type)
|
||||
assertNotNull(kp.private)
|
||||
assertNotNull(kp.public)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openssh-v1 Ed25519 loads correctly`() {
|
||||
assumeTrue("ssh-keygen required", opensshEd25519 != null)
|
||||
val kp = SSHKeyLoader.load(opensshEd25519!!)
|
||||
assertEquals(KeyType.ED25519, kp.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openssh-v1 RSA loads correctly`() {
|
||||
assumeTrue("ssh-keygen required", opensshRsa != null)
|
||||
val kp = SSHKeyLoader.load(opensshRsa!!)
|
||||
assertEquals(KeyType.RSA, kp.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openssh-v1 ECDSA P-256 loads correctly`() {
|
||||
assumeTrue("ssh-keygen required", opensshEcdsa256 != null)
|
||||
val kp = SSHKeyLoader.load(opensshEcdsa256!!)
|
||||
assertTrue("Expected ECDSA, got ${kp.type}", kp.type.toString().contains("ecdsa"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy PEM RSA loads correctly`() {
|
||||
assumeTrue("ssh-keygen required", legacyPemRsa != null)
|
||||
val kp = SSHKeyLoader.load(legacyPemRsa!!)
|
||||
assertEquals(KeyType.RSA, kp.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy PEM ECDSA loads correctly`() {
|
||||
assumeTrue("ssh-keygen required", legacyPemEcdsa != null)
|
||||
val kp = SSHKeyLoader.load(legacyPemEcdsa!!)
|
||||
assertTrue("Expected ECDSA, got ${kp.type}", kp.type.toString().contains("ecdsa"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openssh-v1 Ed25519 with passphrase loads correctly`() {
|
||||
assumeTrue("ssh-keygen required", opensshEd25519Pass != null)
|
||||
val kp = SSHKeyLoader.load(opensshEd25519Pass!!, PasswordUtils.createOneOff("test123".toCharArray()))
|
||||
assertEquals(KeyType.ED25519, kp.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openssh-v1 RSA with passphrase loads correctly`() {
|
||||
assumeTrue("ssh-keygen required", opensshRsaPass != null)
|
||||
val kp = SSHKeyLoader.load(opensshRsaPass!!, PasswordUtils.createOneOff("test123".toCharArray()))
|
||||
assertEquals(KeyType.RSA, kp.type)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +1,32 @@
|
|||
package com.roundingmobile.terminalview
|
||||
|
||||
import android.view.MotionEvent
|
||||
import com.roundingmobile.terminalview.engine.MouseEncoding
|
||||
import com.roundingmobile.terminalview.engine.MouseMode
|
||||
import com.roundingmobile.terminalview.engine.ScreenBuffer
|
||||
|
||||
/**
|
||||
* Converts Android touch events into terminal mouse escape sequences.
|
||||
* Sends terminal mouse escape sequences.
|
||||
*
|
||||
* Supports X10, Normal, Button-Event and Any-Event mouse modes with
|
||||
* SGR, URXVT and legacy X10 coordinate encodings.
|
||||
* Only tap (click) events are forwarded as mouse reports — scroll, long-press,
|
||||
* and double-tap always go to the gesture handler so scrollback and text
|
||||
* selection work even when mouse mode is active.
|
||||
*/
|
||||
class MouseReporter(private val onTerminalInput: (ByteArray) -> Unit) {
|
||||
|
||||
private var mouseButtonDown = false
|
||||
|
||||
/**
|
||||
* Convert touch event to terminal mouse escape sequence and send to host.
|
||||
* Returns true if the event was consumed (mouse mode is active for this event type).
|
||||
* Send a mouse click (press + release) at pixel coordinates.
|
||||
* Used when mouse mode is active but only taps are forwarded as mouse events
|
||||
* (scroll and long-press bypass mouse mode for scrollback and text selection).
|
||||
*/
|
||||
fun handleMouseEvent(event: MotionEvent, screen: ScreenBuffer, charWidth: Float, charHeight: Float): Boolean {
|
||||
if (charWidth <= 0f || charHeight <= 0f) return false
|
||||
|
||||
// Convert pixel coordinates to terminal cell (1-based for protocol)
|
||||
val col = (event.x / charWidth).toInt().coerceIn(0, screen.cols - 1) + 1
|
||||
val row = (event.y / charHeight).toInt().coerceIn(0, screen.rows - 1) + 1
|
||||
|
||||
val mode = screen.mouseMode
|
||||
fun sendClick(x: Float, y: Float, screen: ScreenBuffer, charWidth: Float, charHeight: Float) {
|
||||
if (charWidth <= 0f || charHeight <= 0f) return
|
||||
val col = (x / charWidth).toInt().coerceIn(0, screen.cols - 1) + 1
|
||||
val row = (y / charHeight).toInt().coerceIn(0, screen.rows - 1) + 1
|
||||
val encoding = screen.mouseEncoding
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
mouseButtonDown = true
|
||||
sendMouseReport(0, col, row, pressed = true, encoding)
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
mouseButtonDown = false
|
||||
if (mode != MouseMode.X10) {
|
||||
sendMouseReport(0, col, row, pressed = false, encoding)
|
||||
}
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (mode == MouseMode.BUTTON_EVENT && mouseButtonDown) {
|
||||
sendMouseReport(32, col, row, pressed = true, encoding) // 32 = motion flag
|
||||
return true
|
||||
}
|
||||
if (mode == MouseMode.ANY_EVENT) {
|
||||
val btn = if (mouseButtonDown) 32 else 35 // 35 = no button + motion
|
||||
sendMouseReport(btn, col, row, pressed = true, encoding)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
sendMouseReport(0, col, row, pressed = true, encoding)
|
||||
if (screen.mouseMode != MouseMode.X10) {
|
||||
sendMouseReport(0, col, row, pressed = false, encoding)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -156,20 +156,6 @@ class TerminalGestureHandler(
|
|||
}
|
||||
})
|
||||
|
||||
/** Feed the scale detector only — used when mouse mode intercepts single-finger events. */
|
||||
fun feedScaleDetector(event: MotionEvent) {
|
||||
scaleGestureDetector.onTouchEvent(event)
|
||||
// Commit pinch when last finger lifts (mirrors the check in onTouchEvent)
|
||||
if (isPinching && event.actionMasked == MotionEvent.ACTION_UP) {
|
||||
val finalSize = (fontSizeAtPinchStart * pinchScale).coerceIn(minFontSizeSp, maxFontSizeSp)
|
||||
pinchScale = 1.0f
|
||||
isPinching = false
|
||||
listener.onZoomEnd(finalSize)
|
||||
listener.onRequestRender()
|
||||
pinchConsumed = true
|
||||
}
|
||||
}
|
||||
|
||||
fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
scaleGestureDetector.onTouchEvent(event)
|
||||
gestureDetector.onTouchEvent(event)
|
||||
|
|
|
|||
|
|
@ -188,8 +188,15 @@ class TerminalSurfaceView @JvmOverloads constructor(
|
|||
clearSelection()
|
||||
return
|
||||
}
|
||||
// Check if tap is on a URL
|
||||
// Mouse mode: send click to host (scroll/long-press/double-tap bypass mouse mode)
|
||||
val screen = screenBuffer
|
||||
if (mouseReportingEnabled && screen != null && screen.mouseMode != MouseMode.NONE) {
|
||||
mouseReporter.sendClick(x, y, screen, renderer.charWidth, renderer.charHeight)
|
||||
screenBuffer?.scrollToBottom()
|
||||
requestRender()
|
||||
return
|
||||
}
|
||||
// Check if tap is on a URL
|
||||
if (screen != null && renderer.charHeight > 0f && renderer.charWidth > 0f) {
|
||||
val viewportRow = (y / renderer.charHeight).toInt().coerceIn(0, screen.rows - 1)
|
||||
val col = (x / renderer.charWidth).toInt().coerceIn(0, screen.cols - 1)
|
||||
|
|
@ -778,18 +785,6 @@ class TerminalSurfaceView @JvmOverloads constructor(
|
|||
return true
|
||||
}
|
||||
|
||||
// Always feed scale detector so pinch-zoom works even in mouse mode
|
||||
gestureHandler.feedScaleDetector(event)
|
||||
|
||||
// Mouse reporting: when active, intercept single-finger touch and send escape sequences to host.
|
||||
// Multi-finger gestures (pinch-zoom) bypass mouse reporting.
|
||||
val screen = screenBuffer
|
||||
if (mouseReportingEnabled && screen != null && screen.mouseMode != MouseMode.NONE) {
|
||||
if (!gestureHandler.isPinching && event.pointerCount == 1) {
|
||||
if (mouseReporter.handleMouseEvent(event, screen, renderer.charWidth, renderer.charHeight)) return true
|
||||
}
|
||||
}
|
||||
|
||||
// Release edge effects on touch down
|
||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||
edgeEffectTop.onRelease()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue