diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9518a2ed..c3821da8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: uses: connectbot/actions/.github/workflows/publish-maven.yml@9b826116f0d49329fbe10985a5abdfa49562f0a0 # v0.2.0 with: maven_group: org.connectbot.sshlib - modules: protocol,sshlib + modules: protocol,sshlib,sshlib-webauthn secrets: MAVEN_GPG_PRIVATE_KEY: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} MAVEN_GPG_KEYID: ${{ secrets.MAVEN_GPG_KEYID }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8510c93..ac8c426d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Changes for library users since `0.6.5`. +## Added + +- Add OpenSSH WebAuthn signature packing and optional `sshlib-webauthn` + request/response adapter. + ## [0.6.5][0.6.5] Changes for library users since `0.6.4`. diff --git a/README.md b/README.md index 3307651cc..0d10ab280 100644 --- a/README.md +++ b/README.md @@ -342,3 +342,9 @@ Apache License 2.0 - See LICENSE file ## Copyright Copyright 2019-2026, [Kenny Root](https://github.com/kruton/) + +### Optional WebAuthn adapter + +Use `org.connectbot.sshlib:sshlib-webauthn` at the same version as `sshlib` for +Android Credential Manager request/response JSON handling. The core library +does not depend on JSON. See [WebAuthn integration](docs/SK_AUTH.md#webauthn--android-credential-manager). diff --git a/build.gradle.kts b/build.gradle.kts index ca0b120e1..3a78c0bad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,6 +43,7 @@ allprojects { } dependencies { + kover(project(":sshlib-webauthn")) kover(project(":protocol")) kover(project(":sshlib")) } @@ -50,7 +51,7 @@ dependencies { tasks.register("stageMavenPublication") { group = "publishing" description = "Stages both Maven modules for isolated attestation and publication" - dependsOn(":protocol:stageMavenPublication", ":sshlib:stageMavenPublication") + dependsOn(":protocol:stageMavenPublication", ":sshlib:stageMavenPublication", ":sshlib-webauthn:stageMavenPublication") } // Only data crosses into the isolated SonarCloud workflow. Never ship Gradle caches diff --git a/docs/SK_AUTH.md b/docs/SK_AUTH.md index dbd12dd96..a19ba9f6e 100644 --- a/docs/SK_AUTH.md +++ b/docs/SK_AUTH.md @@ -67,3 +67,38 @@ The following are **not** handled by this library: - CTAP2 transport (USB-HID, NFC, BLE). - Credential creation (`ssh-keygen -t *-sk` equivalent). - Parsing `ssh-keygen`'s OpenSSH-format SK private-key files. (The library decodes the SK *public-key blob*; the surrounding OpenSSH private-key envelope is the caller's responsibility.) + +## WebAuthn / Android Credential Manager + +Add the optional `org.connectbot.sshlib:sshlib-webauthn` artifact (same version as +`sshlib`) for JSON request/response handling. Core `sshlib` has no JSON dependency. + +Register an ES256/P-256 passkey in your application and retain its credential ID +and uncompressed P-256 public point. Associate your Android application with the +RP domain using Digital Asset Links. Build the SSH public key with +`SkAuthHelpers.buildAuthPublicKey(SkAlgorithm.ECDSA_P256, point, rpId)` and install +its public blob in the server's `authorized_keys` under the existing +`sk-ecdsa-sha2-nistp256@openssh.com` key type. + +Inside `onSignatureRequest`: + +```kotlin +val optionsJson = WebAuthnAuth.createRequestJson(rpId, credentialId, dataToSign) +// Pass optionsJson to Android's GetPublicKeyCredentialOption, invoke Credential +// Manager, then obtain PublicKeyCredential.authenticationResponseJson. +return WebAuthnAuth.signatureFromResponseJson(responseJson, rpId, dataToSign) +``` + +Requests require user verification. The challenge is the original SSH signing +bytes encoded with unpadded base64url, **not their SHA-256 hash**. The adapter +checks the challenge and RP hash before packing the assertion; the server checks +its cryptographic signature and presence/verification policy. + +OpenSSH expects client data to begin with compact `type`, `challenge`, `origin` +fields in that order. The adapter preserves signed JSON bytes and rejects +incompatible provider encodings; rewriting JSON after signing invalidates the +signature. Registration, Android APIs and provider selection remain app-owned. +Actual provider interoperability must be tested in the consuming application. + +Applications that already decode assertions can instead use the dependency-free +`SkSignatureBlob.packWebAuthn(signature, authenticatorData, clientDataJson, origin)`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index abdb97c2a..84262eeef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +serialization = "1.9.0" kotlin = "2.4.21" clikt = "5.1.0" spotless = "8.10.4" @@ -28,6 +29,7 @@ cyclonedx = "3.5.1" sonarqube = "7.5.0.8588" [libraries] +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } kaitai-runtime = { module = "io.kaitai:kaitai-struct-runtime", version.ref = "kaitai" } kaitai-compiler = { module = "io.kaitai:kaitai-struct-compiler_2.13", version.ref = "kaitai" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/protocol/src/main/resources/kaitai/ssh_signature.ksy b/protocol/src/main/resources/kaitai/ssh_signature.ksy index 7da3315cd..1ea372e1b 100644 --- a/protocol/src/main/resources/kaitai/ssh_signature.ksy +++ b/protocol/src/main/resources/kaitai/ssh_signature.ksy @@ -12,6 +12,7 @@ meta: - ssh_rsa_signature_blob - sk_ed25519_signature_blob - sk_ecdsa_p256_signature_blob + - webauthn_sk_signature_blob doc-ref: RFC 4253 section 6.6 doc: > Generic SSH signature structure. The signature blob format is defined @@ -45,4 +46,6 @@ seq: '"ssh-mldsa-87"': ssh_mldsa_signature_blob(4627) '"ssh-mldsa44-ed25519"': ssh_mldsa_signature_blob(2484) '"ssh-mldsa87-p384"': ssh_mldsa_p384_signature_blob + '"webauthn-sk-ecdsa-sha2-nistp256@openssh.com"': webauthn_sk_signature_blob + '"webauthn-sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"': webauthn_sk_signature_blob _: byte_string diff --git a/protocol/src/main/resources/kaitai/webauthn_authenticator_data.ksy b/protocol/src/main/resources/kaitai/webauthn_authenticator_data.ksy new file mode 100644 index 000000000..b594b083a --- /dev/null +++ b/protocol/src/main/resources/kaitai/webauthn_authenticator_data.ksy @@ -0,0 +1,18 @@ +meta: + id: webauthn_authenticator_data + endian: be +seq: +- id: rp_id_hash + size: 32 +- id: flags + type: u1 + doc: Assertion flags; AT (0x40, attested credential data) must be clear. + valid: + expr: (_ & 0x40) == 0 +- id: counter + type: u4 +- id: extensions + size-eos: true + doc: Extension bytes must be present exactly when ED (0x80) is set. + valid: + expr: ((flags & 0x80) != 0) == (_.size > 0) diff --git a/protocol/src/main/resources/kaitai/webauthn_sk_signature_blob.ksy b/protocol/src/main/resources/kaitai/webauthn_sk_signature_blob.ksy new file mode 100644 index 000000000..4c4ecb7a4 --- /dev/null +++ b/protocol/src/main/resources/kaitai/webauthn_sk_signature_blob.ksy @@ -0,0 +1,25 @@ +meta: + id: webauthn_sk_signature_blob + endian: be + imports: + - ecdsa_signature_blob + - byte_string +seq: +- id: signature + type: ecdsa_signature_blob +- id: flags + type: u1 + doc: Assertion flags; AT (0x40, attested credential data) must be clear. + valid: + expr: (_ & 0x40) == 0 +- id: counter + type: u4 +- id: origin + type: byte_string +- id: client_data + type: byte_string +- id: extensions + type: byte_string + doc: Extension bytes must be present exactly when ED (0x80) is set. + valid: + expr: ((flags & 0x80) != 0) == (_.data.size > 0) diff --git a/protocol/src/test/kotlin/org/connectbot/sshlib/protocol/WebauthnValidationTest.kt b/protocol/src/test/kotlin/org/connectbot/sshlib/protocol/WebauthnValidationTest.kt new file mode 100644 index 000000000..ad56c5c19 --- /dev/null +++ b/protocol/src/test/kotlin/org/connectbot/sshlib/protocol/WebauthnValidationTest.kt @@ -0,0 +1,71 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.protocol + +import io.kaitai.struct.ByteBufferKaitaiStream +import io.kaitai.struct.KaitaiStream.ValidationExprError +import org.junit.jupiter.api.Test +import java.nio.ByteBuffer +import kotlin.test.assertFailsWith + +class WebauthnValidationTest { + private fun sshString(bytes: ByteArray): ByteArray = ByteBuffer.allocate(Int.SIZE_BYTES + bytes.size).putInt(bytes.size).put(bytes).array() + + private fun authenticator(flags: Int, extensions: ByteArray): WebauthnAuthenticatorData { + val bytes = ByteArray(32) + byteArrayOf(flags.toByte()) + ByteArray(Int.SIZE_BYTES) + extensions + return WebauthnAuthenticatorData(ByteBufferKaitaiStream(bytes)).apply { _read() } + } + + private fun signature(flags: Int, extensions: ByteArray): WebauthnSkSignatureBlob { + val material = sshString(sshString(byteArrayOf(1)) + sshString(byteArrayOf(2))) + val bytes = material + byteArrayOf(flags.toByte()) + ByteArray(Int.SIZE_BYTES) + + sshString("https://example.com".toByteArray()) + sshString("{}".toByteArray()) + sshString(extensions) + return WebauthnSkSignatureBlob(ByteBufferKaitaiStream(bytes)).apply { _read() } + } + + @Test fun rejectsInvalidAssertionFlagsAndExtensionsDuringParsing() { + for (flags in listOf(0x01, 0x41, 0x81, 0xc1)) { + for (extensions in listOf(byteArrayOf(), byteArrayOf(0xa0.toByte()))) { + val valid = (flags == 0x01 && extensions.isEmpty()) || (flags == 0x81 && extensions.isNotEmpty()) + if (valid) { + authenticator(flags, extensions)._check() + signature(flags, extensions)._check() + } else { + assertFailsWith { authenticator(flags, extensions) } + assertFailsWith { signature(flags, extensions) } + } + } + } + } + + @Test fun rejectsInvalidAssertionFlagsAndExtensionsDuringSerialization() { + for (flags in listOf(0x41, 0x81)) { + val auth = authenticator(0x01, byteArrayOf()).apply { setFlags(flags) } + val sig = signature(0x01, byteArrayOf()).apply { setFlags(flags) } + assertFailsWith { auth.toByteArray() } + assertFailsWith { sig.toByteArray() } + } + val extensions = byteArrayOf(0xa0.toByte()) + val auth = authenticator(0x01, byteArrayOf()).apply { setExtensions(extensions) } + val sig = signature(0x01, byteArrayOf()).apply { + setExtensions(ByteString(ByteBufferKaitaiStream(sshString(extensions))).apply { _read() }) + } + assertFailsWith { auth.toByteArray() } + assertFailsWith { sig.toByteArray() } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 15631d4e4..031aa4ec0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,5 +21,6 @@ rootProject.name = "cbssh" include(":sshlib") include(":testapp") include(":protocol") +include(":sshlib-webauthn") enableFeaturePreview("STABLE_CONFIGURATION_CACHE") diff --git a/sshlib-webauthn/README.md b/sshlib-webauthn/README.md new file mode 100644 index 000000000..1568f0c33 --- /dev/null +++ b/sshlib-webauthn/README.md @@ -0,0 +1,3 @@ +# Module ConnectBot SSH WebAuthn Adapter + +Optional request/response JSON adapter for ES256 passkey SSH authentication. diff --git a/sshlib-webauthn/api.txt b/sshlib-webauthn/api.txt new file mode 100644 index 000000000..a2dbad5af --- /dev/null +++ b/sshlib-webauthn/api.txt @@ -0,0 +1,11 @@ +// Signature format: 4.0 +package org.connectbot.sshlib.webauthn { + + public final class WebAuthnAuth { + method public String createRequestJson(String rpId, byte[] credentialId, byte[] dataToSign); + method public byte[] signatureFromResponseJson(String responseJson, String rpId, byte[] dataToSign); + field public static final org.connectbot.sshlib.webauthn.WebAuthnAuth INSTANCE; + } + +} + diff --git a/sshlib-webauthn/build.gradle.kts b/sshlib-webauthn/build.gradle.kts new file mode 100644 index 000000000..ea056d362 --- /dev/null +++ b/sshlib-webauthn/build.gradle.kts @@ -0,0 +1,123 @@ +/* + * Copyright 2025 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.vanniktech.maven.publish.DeploymentValidation +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.publish) + alias(libs.plugins.dokka) + alias(libs.plugins.metalava) + alias(libs.plugins.kover) + alias(libs.plugins.cyclonedx) + `java-library` +} + +apply(from = rootProject.file("gradle/publication-staging.gradle.kts")) + +dependencies { + api(project(":sshlib")) + implementation(project(":protocol")) + implementation(libs.kotlinx.serialization.json) + testImplementation(kotlin("test")) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) +} + +tasks.test { useJUnitPlatform() } + +kover { + currentProject { + sources { + excludeJava.set(true) + } + } +} + +java { + withSourcesJar() + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +val gitHubUrl = "https://github.com/connectbot/cbssh" + +dokka { + moduleName.set("ConnectBot SSH WebAuthn Adapter") + + dokkaSourceSets.configureEach { + includes.from("README.md") + sourceLink { + localDirectory.set(file("./")) + remoteUrl.set(uri("$gitHubUrl/blob/main")) + remoteLineSuffix.set("#L") + } + } + + pluginsConfiguration { + html.footerMessage.set("Copyright Kenny Root") + } +} + +tasks.cyclonedxDirectBom { + includeConfigs.set(listOf("runtimeClasspath")) + includeLicenseText.set(true) + jsonOutput.set(layout.buildDirectory.file("reports/cyclonedx-direct/${project.name}-bom.json")) +} + +publishing { + publications { + withType().configureEach { + artifact(tasks.cyclonedxDirectBom.flatMap { it.jsonOutput }) { + classifier = "cyclonedx" + extension = "json" + } + } + } +} + +mavenPublishing { + publishToMavenCentral(automaticRelease = true, validateDeployment = DeploymentValidation.PUBLISHED) + signAllPublications() + + pom { + name.set("sshlib-webauthn") + description.set("Optional WebAuthn adapter for ConnectBot SSH client library") + inceptionYear.set("2019") + url.set(gitHubUrl) + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("kruton") + name.set("Kenny Root") + url.set("https://github.com/kruton/") + } + } + scm { + connection.set("scm:git:$gitHubUrl.git") + developerConnection.set("$gitHubUrl.git") + url.set(gitHubUrl) + } + } +} diff --git a/sshlib-webauthn/src/main/kotlin/org/connectbot/sshlib/webauthn/WebAuthnAuth.kt b/sshlib-webauthn/src/main/kotlin/org/connectbot/sshlib/webauthn/WebAuthnAuth.kt new file mode 100644 index 000000000..a00deb797 --- /dev/null +++ b/sshlib-webauthn/src/main/kotlin/org/connectbot/sshlib/webauthn/WebAuthnAuth.kt @@ -0,0 +1,120 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.webauthn + +import io.kaitai.struct.ByteBufferKaitaiStream +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.protocol.WebauthnAuthenticatorData +import org.connectbot.sshlib.sk.SkSignatureBlob +import java.security.MessageDigest +import java.util.Base64 + +/** Platform-independent request and response adapter for ES256 SSH passkeys. */ +public object WebAuthnAuth { + /** Creates Credential Manager request options, requiring user verification. */ + public fun createRequestJson(rpId: String, credentialId: ByteArray, dataToSign: ByteArray): String { + if (rpId.isBlank() || credentialId.isEmpty() || dataToSign.isEmpty()) throw SshException("RP ID, credential ID and challenge must not be empty") + return buildJsonObject { + put("rpId", rpId) + put("challenge", encode(dataToSign)) + put("userVerification", "required") + putJsonArray("allowCredentials") { + addJsonObject { + put("type", "public-key") + put("id", encode(credentialId)) + } + } + }.toString() + } + + /** + * Converts Credential Manager authentication response JSON to an SSH signature. + * Preserves signed clientDataJSON exactly; incompatible OpenSSH formatting is rejected. + * OpenSSH performs cryptographic signature verification. + * @throws SshException if the response is malformed or incompatible. + */ + public fun signatureFromResponseJson(responseJson: String, rpId: String, dataToSign: ByteArray): ByteArray = try { + val root = Json.parseToJsonElement(responseJson).jsonObject + if (root.string("type") != "public-key") { + throw SshException("Expected public-key credential") + } + + val response = root.getValue("response").jsonObject + val clientBytes = decode(response.string("clientDataJSON")) + val client = Json.parseToJsonElement(clientBytes.toString(Charsets.UTF_8)).jsonObject + + if (client.string("type") != "webauthn.get") { + throw SshException("Expected webauthn.get assertion") + } + + if (!decode(client.string("challenge")).contentEquals(dataToSign)) { + throw SshException("WebAuthn challenge mismatch") + } + + val origin = client.string("origin") + val prefix = "{\"type\":\"webauthn.get\",\"challenge\":\"${encode(dataToSign)}\",\"origin\":\"$origin\"".toByteArray(Charsets.UTF_8) + + if (clientBytes.size < prefix.size || !clientBytes.copyOfRange(0, prefix.size).contentEquals(prefix)) { + throw SshException("clientDataJSON encoding is incompatible with OpenSSH; signed JSON cannot be rewritten") + } + + val auth = decode(response.string("authenticatorData")) + val authenticatorData = WebauthnAuthenticatorData(ByteBufferKaitaiStream(auth)).apply { _read() } + val rpHash = MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)) + + if (!authenticatorData.rpIdHash().contentEquals(rpHash)) { + throw SshException("WebAuthn RP ID mismatch") + } + + SkSignatureBlob.packWebAuthn(decode(response.string("signature")), auth, clientBytes, origin) + } catch (e: SshException) { + throw e + } catch (e: Exception) { + throw SshException("Malformed WebAuthn assertion response", e) + } + + private fun JsonObject.string(name: String): String { + val value = getValue(name).jsonPrimitive + + if (!value.isString) { + throw SshException("Expected string for $name") + } + + return value.content + } + + private fun encode(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + + private fun decode(value: String): ByteArray { + if (!value.matches(Regex("[A-Za-z0-9_-]*={0,2}"))) { + throw SshException("Invalid base64url assertion field") + } + + return Base64.getUrlDecoder().decode(value) + } +} diff --git a/sshlib-webauthn/src/test/kotlin/org/connectbot/sshlib/webauthn/WebAuthnAuthTest.kt b/sshlib-webauthn/src/test/kotlin/org/connectbot/sshlib/webauthn/WebAuthnAuthTest.kt new file mode 100644 index 000000000..6318cfc9c --- /dev/null +++ b/sshlib-webauthn/src/test/kotlin/org/connectbot/sshlib/webauthn/WebAuthnAuthTest.kt @@ -0,0 +1,98 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.webauthn + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.sk.SkSignatureBlob +import org.junit.jupiter.api.Test +import java.security.MessageDigest +import java.util.Base64 +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class WebAuthnAuthTest { + private val challenge = byteArrayOf(1, 2, 3) + private val rp = "example.com" + private fun enc(b: ByteArray) = Base64.getUrlEncoder().withoutPadding().encodeToString(b) + private val der = byteArrayOf(0x30, 6, 2, 1, 1, 2, 1, 2) + private fun auth() = MessageDigest.getInstance("SHA-256").digest(rp.toByteArray()) + byteArrayOf(0x1d, 0, 0, 0, 0) + private fun client(origin: String) = "{\"type\":\"webauthn.get\",\"challenge\":\"${enc(challenge)}\",\"origin\":\"$origin\"}".toByteArray() + private fun response(client: ByteArray, auth: ByteArray = auth()) = buildJsonObject { + put("type", "public-key") + putJsonObject("response") { + put("signature", enc(der)) + put("authenticatorData", enc(auth)) + put("clientDataJSON", enc(client)) + } + }.toString() + + @Test fun request() { + val request = Json.parseToJsonElement(WebAuthnAuth.createRequestJson(rp, byteArrayOf(4), challenge)).jsonObject + assertEquals("AQID", request["challenge"]!!.jsonPrimitive.content) + assertEquals("required", request["userVerification"]!!.jsonPrimitive.content) + assertEquals("BA", request["allowCredentials"]!!.jsonArray[0].jsonObject["id"]!!.jsonPrimitive.content) + } + + @Test fun responsePreservesBytes() { + for (origin in listOf("https://example.com", "android:apk-key-hash:abc")) { + assertContentEquals(SkSignatureBlob.packWebAuthn(der, auth(), client(origin), origin), WebAuthnAuth.signatureFromResponseJson(response(client(origin)), rp, challenge)) + } + } + + @Test fun rejectsInvalidResponses() { + val valid = response(client("https://example.com")) + for (bad in listOf("{}", "not json", valid.replace("public-key", "password"), valid.replace(enc(der), "!!!"), response(client("https://example.com").reversedArray()))) { + assertFailsWith { WebAuthnAuth.signatureFromResponseJson(bad, rp, challenge) } + } + assertFailsWith { WebAuthnAuth.signatureFromResponseJson(valid, "wrong.example", challenge) } + assertFailsWith { WebAuthnAuth.signatureFromResponseJson(valid, rp, byteArrayOf(9)) } + assertFailsWith { WebAuthnAuth.signatureFromResponseJson(response((" " + client("https://example.com").toString(Charsets.UTF_8)).toByteArray()), rp, challenge) } + } + + @Test fun rejectsTruncatedAuthenticatorData() { + val origin = "https://example.com" + val valid = auth() + for (length in valid.indices) { + val truncated = valid.copyOf(length) + assertFailsWith("Adapter accepted $length bytes") { + WebAuthnAuth.signatureFromResponseJson(response(client(origin), truncated), rp, challenge) + } + assertFailsWith("Packer accepted $length bytes") { + SkSignatureBlob.packWebAuthn(der, truncated, client(origin), origin) + } + } + } + + @Test fun rejectsMalformedAuthenticatorAndSignatures() { + for (data in listOf(ByteArray(36), auth().apply { this[32] = 0x40 }, auth().apply { this[32] = 0x80.toByte() }, auth() + byteArrayOf(1))) { + assertFailsWith { SkSignatureBlob.packWebAuthn(der, data, client("x"), "x") } + } + assertFailsWith { SkSignatureBlob.packWebAuthn(byteArrayOf(), auth(), client("x"), "x") } + } +} diff --git a/sshlib/api.txt b/sshlib/api.txt index fe04f19ef..8f2084229 100644 --- a/sshlib/api.txt +++ b/sshlib/api.txt @@ -1110,6 +1110,7 @@ package org.connectbot.sshlib.sk { public final class SkSignatureBlob { method @KotlinOnly public byte[] pack(org.connectbot.sshlib.sk.SkAlgorithm algorithm, byte[] rawSignature, byte flags, kotlin.UInt counter); + method public byte[] packWebAuthn(byte[] signature, byte[] authenticatorData, byte[] clientDataJson, String origin); property public static byte FLAG_USER_PRESENCE; property public static byte FLAG_USER_VERIFICATION; field public static final byte FLAG_USER_PRESENCE = 1; // 0x1 diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshCryptoPolicy.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshCryptoPolicy.kt index 6bb83f479..41856beb1 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshCryptoPolicy.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshCryptoPolicy.kt @@ -105,7 +105,7 @@ class SshCryptoPolicy private constructor( } val pubkeys = hostKeys.map { entry -> entry.copy(defaultEnabled = entry.defaultEnabled || (certificateAlgorithm(certificateSignatureAlgorithm(entry.name)) == entry.name && signatures.any { it.name == certificateSignatureAlgorithm(entry.name) && it.defaultEnabled })) - } + listOf("sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com", "sk-ssh-ed25519-cert-v01@openssh.com", "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com").map { SshAlgorithm(it, true, true) } + } return mapOf( AlgorithmCategory.KEX to KexEntry.entries.map { SshAlgorithm(it.sshName, true, it in KexEntry.defaults) }, AlgorithmCategory.CIPHER to CipherEntry.entries.map { SshAlgorithm(it.sshName, cipherAvailable(it), it in CipherEntry.defaults) }, @@ -128,9 +128,9 @@ class SshCryptoPolicy private constructor( mldsa.isAvailable() } else { val (signature, keyFactory) = when { - entry.sshName == "ssh-ed25519" -> "Ed25519" to "Ed25519" + entry.sshName in setOf("ssh-ed25519", "sk-ssh-ed25519@openssh.com") -> "Ed25519" to "Ed25519" entry.sshName == "ssh-ed448" -> "Ed448" to "Ed448" - entry.sshName.endsWith("nistp256") -> "SHA256withECDSA" to "EC" + (entry.sshName.endsWith("nistp256") || entry.sshName == "sk-ecdsa-sha2-nistp256@openssh.com") -> "SHA256withECDSA" to "EC" entry.sshName.endsWith("nistp384") -> "SHA384withECDSA" to "EC" entry.sshName.endsWith("nistp521") -> "SHA512withECDSA" to "EC" entry.sshName == "rsa-sha2-512" -> "SHA512withRSA" to "RSA" diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Algorithms.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Algorithms.kt index f3d13881c..b76bbbd92 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Algorithms.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Algorithms.kt @@ -345,6 +345,8 @@ internal enum class SignatureEntry( RSA_SHA2_256("rsa-sha2-256", RsaSignatureAlgorithm), RSA_SHA2_512("rsa-sha2-512", RsaSignatureAlgorithm), SSH_RSA(KEY_TYPE_SSH_RSA, RsaSignatureAlgorithm), + SK_ED25519("sk-ssh-ed25519@openssh.com", SkSignatureAlgorithm), + SK_ECDSA_P256("sk-ecdsa-sha2-nistp256@openssh.com", SkSignatureAlgorithm), ; companion object { diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/CertificateAlgorithms.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/CertificateAlgorithms.kt index 5df1e6d52..28c28a41b 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/CertificateAlgorithms.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/CertificateAlgorithms.kt @@ -40,6 +40,10 @@ internal fun certificateAlgorithm(name: String): String? = when (name) { "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", -> name + CERTIFICATE_SUFFIX + "sk-ssh-ed25519@openssh.com" -> "sk-ssh-ed25519$CERTIFICATE_SUFFIX" + + "sk-ecdsa-sha2-nistp256@openssh.com" -> "sk-ecdsa-sha2-nistp256$CERTIFICATE_SUFFIX" + else -> null } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/HostKeyProof.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/HostKeyProof.kt index d691a32cc..444e0477c 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/HostKeyProof.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/HostKeyProof.kt @@ -47,9 +47,9 @@ internal fun verifyHostKeyProofs( algorithm in setOf("rsa-sha2-256", "rsa-sha2-512") && algorithm in allowedAlgorithms && (negotiatedAlgorithm !in setOf("ssh-rsa", "rsa-sha2-256", "rsa-sha2-512") || algorithm == negotiatedAlgorithm) } else { - algorithm == key.type && algorithm in allowedAlgorithms + signatureMatchesNegotiatedAlgorithm(algorithm, key.type) && key.type in allowedAlgorithms } - permitted && SignatureVerifier.verify(key.encoded, bytes, hostKeyProofData(sessionId, key.encoded), algorithm) + permitted && SignatureVerifier.verify(key.encoded, bytes, hostKeyProofData(sessionId, key.encoded), signatureVerificationAlgorithm(algorithm)) } } catch (_: Exception) { false diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureAlgorithms.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureAlgorithms.kt new file mode 100644 index 000000000..ae2171c93 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureAlgorithms.kt @@ -0,0 +1,39 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +private const val SK_ECDSA = "sk-ecdsa-sha2-nistp256@openssh.com" + +/** Resolve signature-format aliases to the algorithm that verifies them. */ +internal fun signatureVerificationAlgorithm(signatureAlgorithm: String): String = when (signatureAlgorithm) { + "webauthn-sk-ecdsa-sha2-nistp256@openssh.com", + "webauthn-sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + -> SK_ECDSA + + else -> signatureAlgorithm +} + +/** Match a wire signature to the negotiated algorithm without allowing RSA hash substitution. */ +internal fun signatureMatchesNegotiatedAlgorithm(signatureAlgorithm: String, negotiatedAlgorithm: String): Boolean = !negotiatedAlgorithm.endsWith(CERTIFICATE_SUFFIX) && + signatureVerificationAlgorithm(signatureAlgorithm) == negotiatedAlgorithm + +/** Check key compatibility independently of the negotiated signature/hash choice. */ +internal fun signatureAlgorithmMatchesKeyType(signatureAlgorithm: String, keyType: String): Boolean = when (val algorithm = signatureVerificationAlgorithm(signatureAlgorithm)) { + "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512" -> keyType == "ssh-rsa" + else -> algorithm == keyType +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt index 92fc6e8ee..8d6579bb0 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SignatureVerifier.kt @@ -52,7 +52,7 @@ internal object SignatureVerifier { // RFC 8332 §3.2: reject if the algorithm in the signature blob doesn't match // what was negotiated. Prevents a server from downgrading e.g. rsa-sha2-256 → ssh-rsa. - if (expectedAlgorithm.endsWith(CERTIFICATE_SUFFIX) || sig.algorithmName() != expectedAlgorithm) { + if (!signatureMatchesNegotiatedAlgorithm(sig.algorithmName(), expectedAlgorithm)) { return false } @@ -60,7 +60,7 @@ internal object SignatureVerifier { pubKey._read() if (MldsaAlgorithm.fromSshName(pubKey.algorithmName()) != null && !pubKey._io().isEof) return false - if (!isAlgorithmCompatibleWithKeyType(expectedAlgorithm, pubKey.algorithmName())) { + if (!signatureAlgorithmMatchesKeyType(expectedAlgorithm, pubKey.algorithmName())) { return false } if (expectedAlgorithm.startsWith("ecdsa-sha2-") && @@ -69,7 +69,7 @@ internal object SignatureVerifier { return false } - val algorithm = SignatureEntry.fromSshName(sig.algorithmName())?.algorithm + val algorithm = SignatureEntry.fromSshName(expectedAlgorithm)?.algorithm ?: return false return algorithm.verify(pubKey, sig, exchangeHash) @@ -99,16 +99,11 @@ internal object SignatureVerifier { val keyType = pubKey.algorithmName() // Ensure the sig algorithm is compatible with the key type to prevent cross-type forgery. - if (!isAlgorithmCompatibleWithKeyType(sigAlgorithm, keyType)) { + if (!signatureAlgorithmMatchesKeyType(sigAlgorithm, keyType)) { return false } - val algorithm = SignatureEntry.fromSshName(sigAlgorithm)?.algorithm ?: return false + val algorithm = SignatureEntry.fromSshName(signatureVerificationAlgorithm(sigAlgorithm))?.algorithm ?: return false return algorithm.verify(pubKey, sig, data) } - - private fun isAlgorithmCompatibleWithKeyType(algorithm: String, keyType: String): Boolean = when (algorithm) { - "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512" -> keyType == "ssh-rsa" - else -> algorithm == keyType - } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SkSignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SkSignatureAlgorithm.kt new file mode 100644 index 000000000..e7dc239cd --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SkSignatureAlgorithm.kt @@ -0,0 +1,106 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.protocol.EcdsaSignatureBlob +import org.connectbot.sshlib.protocol.SkEcdsaP256PublicKeyBlob +import org.connectbot.sshlib.protocol.SkEcdsaP256SignatureBlob +import org.connectbot.sshlib.protocol.SkEd25519PublicKeyBlob +import org.connectbot.sshlib.protocol.SkEd25519SignatureBlob +import org.connectbot.sshlib.protocol.SshPublicKey +import org.connectbot.sshlib.protocol.SshSignature +import org.connectbot.sshlib.protocol.WebauthnSkSignatureBlob +import org.connectbot.sshlib.sk.SkSignatureBlob.ED25519_RAW_SIGNATURE_SIZE +import java.nio.ByteBuffer +import java.security.MessageDigest +import java.security.PrivateKey +import java.util.Base64 + +internal object SkSignatureAlgorithm : SshSignatureAlgorithm { + private const val ED25519_RAW_KEY_SIZE: Int = 32 + + override fun sign(algorithmName: String, privateKey: PrivateKey, data: ByteArray): ByteArray = throw SshException("Security keys require an external signer") + + override fun verify(pubKey: SshPublicKey, sig: SshSignature, data: ByteArray): Boolean { + return try { + fun hash(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes) + val application: ByteArray + val flags: Int + val counter: Long + val rawKey: ByteArray + val rawSignature: ByteArray + var extensions = byteArrayOf() + var messageHash = hash(data) + val ed = pubKey.keyBlob() is SkEd25519PublicKeyBlob + if (ed) { + val key = pubKey.keyBlob() as SkEd25519PublicKeyBlob + val signature = sig.signatureBlob() as SkEd25519SignatureBlob + application = key.application().data() + rawKey = key.publicKey().data() + rawSignature = signature.signature().data() + if (rawKey.size != ED25519_RAW_KEY_SIZE || rawSignature.size != ED25519_RAW_SIGNATURE_SIZE) return false + flags = signature.flags() + counter = signature.counter() + } else { + val key = pubKey.keyBlob() as SkEcdsaP256PublicKeyBlob + application = key.application().data() + rawKey = key.publicKey().data() + val material: EcdsaSignatureBlob + when (val signature = sig.signatureBlob()) { + is SkEcdsaP256SignatureBlob -> { + flags = signature.flags() + counter = signature.counter() + material = signature.signature() + } + + is WebauthnSkSignatureBlob -> { + flags = signature.flags() + counter = signature.counter() + material = signature.signature() + extensions = signature.extensions().data() + val origin = signature.origin().data() + if (origin.any { it == '\u0000'.code.toByte() || it == '"'.code.toByte() }) return false + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(data) + val prefix = "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"".toByteArray() + origin + byteArrayOf('"'.code.toByte()) + val client = signature.clientData().data() + if (client.size < prefix.size || !client.copyOfRange(0, prefix.size).contentEquals(prefix)) return false + messageHash = hash(client) + } + + else -> return false + } + if (!material.blob()._io().isEof) return false + val r = material.blob().r().body() + val s = material.blob().s().body() + if (r.isEmpty() || s.isEmpty() || r[0] < 0 || s[0] < 0) return false + rawSignature = encodeMpint(r) + encodeMpint(s) + } + if (application.any { it == '\u0000'.code.toByte() } || !pubKey._io().isEof) return false + val counterBytes = ByteBuffer.allocate(Int.SIZE_BYTES).putInt(counter.toInt()).array() + val signed = hash(application) + byteArrayOf(flags.toByte()) + counterBytes + extensions + messageHash + val name = if (ed) "ssh-ed25519" else "ecdsa-sha2-nistp256" + val keyBytes = encodeSshString(name.toByteArray()) + + (if (ed) byteArrayOf() else encodeSshString("nistp256".toByteArray())) + encodeSshString(rawKey) + val signatureBytes = encodeSshString(name.toByteArray()) + encodeSshString(rawSignature) + SignatureVerifier.verify(keyBytes, signatureBytes, signed, name) + } catch (_: Exception) { + false + } + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkSignatureBlob.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkSignatureBlob.kt index f92420efe..2a4383488 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkSignatureBlob.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkSignatureBlob.kt @@ -17,16 +17,25 @@ package org.connectbot.sshlib.sk +import io.kaitai.struct.ByteBufferKaitaiStream +import io.kaitai.struct.KaitaiStream.ValidationFailedError +import org.connectbot.sshlib.AuthHandler import org.connectbot.sshlib.SshException import org.connectbot.sshlib.crypto.DerReader import org.connectbot.sshlib.crypto.encodeMpint import org.connectbot.sshlib.crypto.encodeSshString +import org.connectbot.sshlib.protocol.EcdsaSignatureBlob +import org.connectbot.sshlib.protocol.WebauthnAuthenticatorData +import org.connectbot.sshlib.protocol.WebauthnSkSignatureBlob +import org.connectbot.sshlib.protocol.createByteString +import org.connectbot.sshlib.protocol.toByteArray +import java.nio.BufferUnderflowException /** * Packs an SK assertion into the OpenSSH SK signature wire format. * * The output is what callers should return from - * [org.connectbot.sshlib.AuthHandler.onSignatureRequest] for an SK + * [AuthHandler.onSignatureRequest] for an SK * public key. The library writes it verbatim into the SSH publickey * `USERAUTH_REQUEST` packet's signature field. * @@ -60,7 +69,7 @@ public object SkSignatureBlob { public const val FLAG_USER_PRESENCE: Byte = 0x01 public const val FLAG_USER_VERIFICATION: Byte = 0x04 - private const val ED25519_RAW_SIGNATURE_SIZE: Int = 64 + internal const val ED25519_RAW_SIGNATURE_SIZE: Int = 64 /** * Pack an SK assertion into the OpenSSH SK signature blob. @@ -91,6 +100,39 @@ public object SkSignatureBlob { encodeUint32BigEndian(counter) } + /** + * Packs an ES256 WebAuthn assertion for OpenSSH. Signed client JSON and extension + * bytes are preserved verbatim. The caller supplies the origin from clientDataJSON. + * Use the original SSH signing bytes as the WebAuthn challenge, without hashing. + * @throws SshException for malformed authenticator data, origin or DER signature. + */ + public fun packWebAuthn( + signature: ByteArray, + authenticatorData: ByteArray, + clientDataJson: ByteArray, + origin: String, + ): ByteArray { + val auth = try { + WebauthnAuthenticatorData(ByteBufferKaitaiStream(authenticatorData)).apply { _read() } + } catch (e: BufferUnderflowException) { + throw SshException("Truncated WebAuthn authenticator data", e) + } catch (e: ValidationFailedError) { + throw SshException("Invalid WebAuthn authenticator data", e) + } + if (origin.isEmpty() || origin.any { it == '\u0000' || it == '"' }) throw SshException("Invalid WebAuthn origin") + val material = EcdsaSignatureBlob(ByteBufferKaitaiStream(encodeSshString(packEcdsaP256MaterialFromDer(signature)))).apply { _read() } + val blob = WebauthnSkSignatureBlob().apply { + setSignature(material) + setFlags(auth.flags()) + setCounter(auth.counter()) + setOrigin(createByteString(origin.toByteArray(Charsets.UTF_8))) + setClientData(createByteString(clientDataJson)) + setExtensions(createByteString(auth.extensions())) + _check() + } + return encodeSshString("webauthn-sk-ecdsa-sha2-nistp256@openssh.com".toByteArray(Charsets.US_ASCII)) + blob.toByteArray() + } + private fun packEd25519Material(rawSignature: ByteArray): ByteArray { if (rawSignature.size != ED25519_RAW_SIGNATURE_SIZE) { throw SshException( @@ -116,7 +158,7 @@ public object SkSignatureBlob { parsed } catch (e: SshException) { throw SshException("Malformed ECDSA DER signature: ${e.message}", e) - } catch (e: java.nio.BufferUnderflowException) { + } catch (e: BufferUnderflowException) { throw SshException("Malformed ECDSA DER signature: truncated", e) } catch (e: IndexOutOfBoundsException) { throw SshException("Malformed ECDSA DER signature: truncated", e) diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SkAuthIntegrationTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SkAuthIntegrationTest.kt index 8710656df..4e5b156d3 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SkAuthIntegrationTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SkAuthIntegrationTest.kt @@ -110,7 +110,9 @@ class SkAuthIntegrationTest { val edAuthLine = authorizedKeysLine(SkAlgorithm.ED25519, ed25519PublicKeyRaw(ed25519KeyPair.public), "ed25519-sk-test") val ecAuthLine = authorizedKeysLine(SkAlgorithm.ECDSA_P256, ecdsaP256PublicKeyPoint(ecdsaP256KeyPair.public), "ecdsa-p256-sk-test") - val combined = edAuthLine + "\n" + ecAuthLine + "\n" + val webBlob = org.connectbot.sshlib.sk.SkPublicKeyEncoder.encode(SkAlgorithm.ECDSA_P256, ecdsaP256PublicKeyPoint(ecdsaP256KeyPair.public), "example.com") + val webLine = "sk-ecdsa-sha2-nistp256@openssh.com ${Base64.getEncoder().encodeToString(webBlob)} webauthn" + val combined = edAuthLine + "\n" + ecAuthLine + "\n" + webLine + "\n" val cmd = "echo '$combined' >> /home/$USERNAME/.ssh/authorized_keys && chown $USERNAME:$USERNAME /home/$USERNAME/.ssh/authorized_keys" val result = opensshContainer.execInContainer("sh", "-c", cmd) check(result.exitCode == 0) { @@ -204,6 +206,32 @@ class SkAuthIntegrationTest { assertTrue(result is AuthResult.Success, "SK ECDSA-P256 auth should succeed, got: $result") } + @Test + fun `authenticates WebAuthn assertions with Android and HTTPS origins`() = runBlocking { + setUpContainerForSk() + for (invalid in listOf(false, true)) { + for (origin in listOf("android:apk-key-hash:test", "https://example.com")) { + val handler = object : AuthHandler { + override suspend fun onPublicKeysNeeded() = listOf( + SkAuthHelpers.buildAuthPublicKey(SkAlgorithm.ECDSA_P256, ecdsaP256PublicKeyPoint(ecdsaP256KeyPair.public), "example.com"), + ) + override suspend fun onSignatureRequest(key: AuthPublicKey, dataToSign: ByteArray): ByteArray { + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(if (invalid) byteArrayOf(9) else dataToSign) + val json = "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"$origin\"}".toByteArray() + val auth = MessageDigest.getInstance("SHA-256").digest("example.com".toByteArray()) + byteArrayOf(0x1d, 0, 0, 0, 0) + val signer = Signature.getInstance("SHA256withECDSA") + signer.initSign(ecdsaP256KeyPair.private) + signer.update(auth + MessageDigest.getInstance("SHA-256").digest(json)) + return SkSignatureBlob.packWebAuthn(signer.sign(), auth, json, origin) + } + override suspend fun onPasswordNeeded(): String? = null + override suspend fun onKeyboardInteractivePrompt(name: String, instruction: String, prompts: List): List? = null + } + assertTrue((connectAndAuthenticate(handler) is AuthResult.Success) != invalid) + } + } + } + private suspend fun connectAndAuthenticate(handler: AuthHandler): AuthResult { val host = opensshContainer.host val port = opensshContainer.getMappedPort(22) diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SignatureAlgorithmsTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SignatureAlgorithmsTest.kt new file mode 100644 index 000000000..013f5a828 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SignatureAlgorithmsTest.kt @@ -0,0 +1,44 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.junit.jupiter.api.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SignatureAlgorithmsTest { + @Test + fun `RSA key compatibility does not authorize negotiated hash substitution`() { + assertTrue(signatureAlgorithmMatchesKeyType("rsa-sha2-512", "ssh-rsa")) + assertFalse(signatureMatchesNegotiatedAlgorithm("ssh-rsa", "rsa-sha2-512")) + assertFalse(signatureMatchesNegotiatedAlgorithm("rsa-sha2-256", "rsa-sha2-512")) + assertTrue(signatureMatchesNegotiatedAlgorithm("rsa-sha2-512", "rsa-sha2-512")) + } + + @Test + fun `WebAuthn aliases only authorize SK ECDSA signatures`() { + val sk = "sk-ecdsa-sha2-nistp256@openssh.com" + for (alias in listOf("webauthn-sk-ecdsa-sha2-nistp256@openssh.com", "webauthn-sk-ecdsa-sha2-nistp256-cert-v01@openssh.com")) { + assertTrue(signatureMatchesNegotiatedAlgorithm(alias, sk)) + assertTrue(signatureAlgorithmMatchesKeyType(alias, sk)) + assertFalse(signatureMatchesNegotiatedAlgorithm(alias, "ecdsa-sha2-nistp256")) + assertFalse(signatureAlgorithmMatchesKeyType(alias, "sk-ssh-ed25519@openssh.com")) + assertFalse(signatureMatchesNegotiatedAlgorithm(alias, "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com")) + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SkHostKeyTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SkHostKeyTest.kt new file mode 100644 index 000000000..f37ad295d --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SkHostKeyTest.kt @@ -0,0 +1,94 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.connectbot.sshlib.sk.SkAlgorithm +import org.connectbot.sshlib.sk.SkPublicKeyEncoder +import org.connectbot.sshlib.sk.SkSignatureBlob +import org.junit.jupiter.api.Test +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.util.Base64 +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SkHostKeyTest { + @Test + fun `native security key host signatures authenticate all FIDO fields`() { + for (algorithm in SkAlgorithm.entries) { + val ed = algorithm == SkAlgorithm.ED25519 + val generator = KeyPairGenerator.getInstance(if (ed) "Ed25519" else "EC") + if (!ed) generator.initialize(ECGenParameterSpec("secp256r1")) + val pair = generator.generateKeyPair() + fun coordinate(bytes: ByteArray) = bytes.takeLast(32).toByteArray().let { ByteArray(32 - it.size) + it } + val raw = if (ed) { + pair.public.encoded.takeLast(32).toByteArray() + } else { + val point = (pair.public as ECPublicKey).w + byteArrayOf(4) + coordinate(point.affineX.toByteArray()) + coordinate(point.affineY.toByteArray()) + } + val key = SkPublicKeyEncoder.encode(algorithm, raw, "ssh:test") + val message = "exchange hash".toByteArray() + fun hash(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes) + for (flags in listOf(0, 1, 5)) { + val counter = byteArrayOf(-1, -1, -1, -1) + val signer = Signature.getInstance(if (ed) "Ed25519" else "SHA256withECDSA") + signer.initSign(pair.private) + signer.update(hash("ssh:test".toByteArray()) + byteArrayOf(flags.toByte()) + counter + hash(message)) + val signature = SkSignatureBlob.pack(algorithm, signer.sign(), flags.toByte(), UInt.MAX_VALUE) + assertTrue(SignatureVerifier.verify(key, signature, message, algorithm.sshName)) + assertFalse(SignatureVerifier.verify(key, signature, message + 0, algorithm.sshName)) + val changed = signature.copyOf().also { it[it.size - 1] = 0 } + assertFalse(SignatureVerifier.verify(key, changed, message, algorithm.sshName)) + assertFalse(SignatureVerifier.verify(key + 0, signature, message, algorithm.sshName)) + assertFalse(SignatureVerifier.verify(key, signature + 0, message, algorithm.sshName)) + } + } + } + + @Test + fun `webauthn host signature binds client data and extensions`() { + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(ECGenParameterSpec("secp256r1")) + val pair = generator.generateKeyPair() + fun coordinate(bytes: ByteArray) = bytes.takeLast(32).toByteArray().let { ByteArray(32 - it.size) + it } + val point = (pair.public as ECPublicKey).w + val raw = byteArrayOf(4) + coordinate(point.affineX.toByteArray()) + coordinate(point.affineY.toByteArray()) + val key = SkPublicKeyEncoder.encode(SkAlgorithm.ECDSA_P256, raw, "ssh:test") + val message = "exchange hash".toByteArray() + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(message) + val client = "{\"type\":\"webauthn.get\",\"challenge\":\"$challenge\",\"origin\":\"https://example.com\",\"crossOrigin\":false}".toByteArray() + fun hash(bytes: ByteArray) = MessageDigest.getInstance("SHA-256").digest(bytes) + for (extended in listOf(false, true)) { + val flags = if (extended) 0x81.toByte() else 1.toByte() + val extensions = if (extended) byteArrayOf(0xa0.toByte()) else byteArrayOf() + val auth = hash("ssh:test".toByteArray()) + byteArrayOf(flags) + byteArrayOf(0, 0, 0, 1) + extensions + val signer = Signature.getInstance("SHA256withECDSA") + signer.initSign(pair.private) + signer.update(auth + hash(client)) + val signature = SkSignatureBlob.packWebAuthn(signer.sign(), auth, client, "https://example.com") + val algorithm = SkAlgorithm.ECDSA_P256.sshName + assertTrue(SignatureVerifier.verify(key, signature, message, algorithm)) + assertFalse(SignatureVerifier.verify(key, signature, message + 0, algorithm)) + assertTrue(SignatureVerifier.verifyWithKeyType(key, signature, message)) + } + } +}