Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,15 @@ allprojects {
}

dependencies {
kover(project(":sshlib-webauthn"))
kover(project(":protocol"))
kover(project(":sshlib"))
}

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
Expand Down
35 changes: 35 additions & 0 deletions docs/SK_AUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[versions]
serialization = "1.9.0"
kotlin = "2.4.21"
clikt = "5.1.0"
spotless = "8.10.4"
Expand Down Expand Up @@ -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" }
Expand Down
3 changes: 3 additions & 0 deletions protocol/src/main/resources/kaitai/ssh_signature.ksy
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
18 changes: 18 additions & 0 deletions protocol/src/main/resources/kaitai/webauthn_authenticator_data.ksy
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 25 additions & 0 deletions protocol/src/main/resources/kaitai/webauthn_sk_signature_blob.ksy
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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<ValidationExprError> { authenticator(flags, extensions) }
assertFailsWith<ValidationExprError> { 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<ValidationExprError> { auth.toByteArray() }
assertFailsWith<ValidationExprError> { 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<ValidationExprError> { auth.toByteArray() }
assertFailsWith<ValidationExprError> { sig.toByteArray() }
}
}
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ rootProject.name = "cbssh"
include(":sshlib")
include(":testapp")
include(":protocol")
include(":sshlib-webauthn")

enableFeaturePreview("STABLE_CONFIGURATION_CACHE")
3 changes: 3 additions & 0 deletions sshlib-webauthn/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Module ConnectBot SSH WebAuthn Adapter

Optional request/response JSON adapter for ES256 passkey SSH authentication.
11 changes: 11 additions & 0 deletions sshlib-webauthn/api.txt
Original file line number Diff line number Diff line change
@@ -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;
}

}

123 changes: 123 additions & 0 deletions sshlib-webauthn/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<MavenPublication>().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)
}
}
}
Loading
Loading