diff --git a/README.md b/README.md index cc72ebef..327f3054 100644 --- a/README.md +++ b/README.md @@ -25,55 +25,14 @@ that runs in reaction to state changes. ## Algorithm Support -### Authentication -- `keyboard-interactive` -- `password` -- `publickey` - -### Host Keys -- `ssh-ed25519` ([RFC 8709](https://tools.ietf.org/html/rfc8709)) -- `ssh-ed448` ([RFC 8709](https://tools.ietf.org/html/rfc8709)) -- `ecdsa-sha2-nistp256` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-3)) -- `ecdsa-sha2-nistp384` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-3)) -- `ecdsa-sha2-nistp521` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-3)) -- `rsa-sha2-512` ([RFC 8332](https://tools.ietf.org/html/rfc8332#section-3)) -- `rsa-sha2-256` ([RFC 8332](https://tools.ietf.org/html/rfc8332#section-3)) -- `ssh-rsa` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) - -### Key Exchange -- `mlkem768x25519-sha256` ([draft-ietf-sshm-mlkem-hybrid-kex](https://datatracker.ietf.org/doc/draft-ietf-sshm-mlkem-hybrid-kex/) (depends on JEP-496 support) -- `curve25519-sha256` ([RFC 8731](https://tools.ietf.org/html/rfc8731)) -- `ecdh-sha2-nistp521` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4)) -- `ecdh-sha2-nistp384` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4)) -- `ecdh-sha2-nistp256` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4)) -- `diffie-hellman-group18-sha512` ([RFC 8268](https://tools.ietf.org/html/rfc8268)) -- `diffie-hellman-group16-sha512` ([RFC 8268](https://tools.ietf.org/html/rfc8268)) -- `diffie-hellman-group14-sha256` ([RFC 8268](https://tools.ietf.org/html/rfc8268)) -- `diffie-hellman-group-exchange-sha256` ([RFC 4419](https://tools.ietf.org/html/rfc4419)) -- `diffie-hellman-group14-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) -- `diffie-hellman-group-exchange-sha1` ([RFC 4419](https://tools.ietf.org/html/rfc4419)) -- `diffie-hellman-group1-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) - -### Encryption -- `chacha20-poly1305@openssh.com` ([draft-ietf-sshm-chacha20-poly1305](https://datatracker.ietf.org/doc/html/draft-ietf-sshm-chacha20-poly1305)) -- `aes256-gcm@openssh.com` ([draft-miller-sshm-aes-gcm](https://datatracker.ietf.org/doc/html/draft-miller-sshm-aes-gcm)) -- `aes128-gcm@openssh.com` ([draft-miller-sshm-aes-gcm](https://datatracker.ietf.org/doc/html/draft-miller-sshm-aes-gcm)) -- `aes256-ctr` ([RFC 4344](https://tools.ietf.org/html/rfc4344#section-4)) -- `aes128-ctr` ([RFC 4344](https://tools.ietf.org/html/rfc4344#section-4)) -- `aes256-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3)) -- `aes128-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3)) -- `3des-cbc` ([RFC 4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.3)) - -### MACs -- `hmac-sha2-512-etm@openssh.com` ([OpenSSH PROTOCOL]( - https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50)) -- `hmac-sha2-256-etm@openssh.com` ([OpenSSH PROTOCOL]( - https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50)) -- `hmac-sha1-etm@openssh.com` ([OpenSSH PROTOCOL]( - https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50)) -- `hmac-sha2-512` ([RFC 4868](https://tools.ietf.org/html/rfc4868)) -- `hmac-sha2-256` ([RFC 4868](https://tools.ietf.org/html/rfc4868)) -- `hmac-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253)) +The library supports a wide range of modern SSH algorithms, including: +- **Authentication**: `publickey` (including FIDO2/SK), `password`, `keyboard-interactive` +- **Host Keys**: Ed25519, Ed448, ECDSA, RSA (SHA-2) +- **Key Exchange**: ML-KEM hybrid, Curve25519, ECDH, DH group-exchange +- **Encryption**: ChaCha20-Poly1305, AES-GCM, AES-CTR +- **MACs**: HMAC-SHA2 (including ETM variants) + +For a complete list of supported algorithms and their respective RFCs, see [docs/ALGORITHMS.md](docs/ALGORITHMS.md). ## Quick Start @@ -146,6 +105,13 @@ try { } ``` +### FIDO2 / Security Key Authentication + +The library supports authentication with `sk-ssh-ed25519@openssh.com` and +`sk-ecdsa-sha2-nistp256@openssh.com` keys. Callers provide their own FIDO2 stack and surface the resulting assertion through the library's helpers. + +See [docs/SK_AUTH.md](docs/SK_AUTH.md) for detailed implementation details and examples. + ### SSH Agent Forwarding Enable SSH agent forwarding to allow remote servers to use your keys: diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md new file mode 100644 index 00000000..4ab452d9 --- /dev/null +++ b/docs/ALGORITHMS.md @@ -0,0 +1,50 @@ +# Supported SSH Algorithms + +This document lists the cryptographic algorithms supported by the ConnectBot SSH library. + +## Authentication +- `keyboard-interactive` +- `password` +- `publickey` (including FIDO2 / Security Key algorithms `sk-ssh-ed25519@openssh.com` and `sk-ecdsa-sha2-nistp256@openssh.com`) + +## Host Keys +- `ssh-ed25519` ([RFC 8709](https://tools.ietf.org/html/rfc8709)) +- `ssh-ed448` ([RFC 8709](https://tools.ietf.org/html/rfc8709)) +- `ecdsa-sha2-nistp256` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-3)) +- `ecdsa-sha2-nistp384` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-3)) +- `ecdsa-sha2-nistp521` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-3)) +- `rsa-sha2-512` ([RFC 8332](https://tools.ietf.org/html/rfc8332#section-3)) +- `rsa-sha2-256` ([RFC 8332](https://tools.ietf.org/html/rfc8332#section-3)) +- `ssh-rsa` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) + +## Key Exchange +- `mlkem768x25519-sha256` ([draft-ietf-sshm-mlkem-hybrid-kex](https://datatracker.ietf.org/doc/draft-ietf-sshm-mlkem-hybrid-kex/)) (depends on JEP-496 support) +- `curve25519-sha256` ([RFC 8731](https://tools.ietf.org/html/rfc8731)) +- `ecdh-sha2-nistp521` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4)) +- `ecdh-sha2-nistp384` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4)) +- `ecdh-sha2-nistp256` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4)) +- `diffie-hellman-group18-sha512` ([RFC 8268](https://tools.ietf.org/html/rfc8268)) +- `diffie-hellman-group16-sha512` ([RFC 8268](https://tools.ietf.org/html/rfc8268)) +- `diffie-hellman-group14-sha256` ([RFC 8268](https://tools.ietf.org/html/rfc8268)) +- `diffie-hellman-group-exchange-sha256` ([RFC 4419](https://tools.ietf.org/html/rfc4419)) +- `diffie-hellman-group14-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) +- `diffie-hellman-group-exchange-sha1` ([RFC 4419](https://tools.ietf.org/html/rfc4419)) +- `diffie-hellman-group1-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) + +## Encryption +- `chacha20-poly1305@openssh.com` ([draft-ietf-sshm-chacha20-poly1305](https://datatracker.ietf.org/doc/html/draft-ietf-sshm-chacha20-poly1305)) +- `aes256-gcm@openssh.com` ([draft-miller-sshm-aes-gcm](https://datatracker.ietf.org/doc/html/draft-miller-sshm-aes-gcm)) +- `aes128-gcm@openssh.com` ([draft-miller-sshm-aes-gcm](https://datatracker.ietf.org/doc/html/draft-miller-sshm-aes-gcm)) +- `aes256-ctr` ([RFC 4344](https://tools.ietf.org/html/rfc4344#section-4)) +- `aes128-ctr` ([RFC 4344](https://tools.ietf.org/html/rfc4344#section-4)) +- `aes256-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3)) +- `aes128-cbc` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-6.3)) +- `3des-cbc` ([RFC 4253](https://datatracker.ietf.org/doc/html/rfc4253#section-6.3)) + +## MACs +- `hmac-sha2-512-etm@openssh.com` ([OpenSSH PROTOCOL](https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50)) +- `hmac-sha2-256-etm@openssh.com` ([OpenSSH PROTOCOL](https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50)) +- `hmac-sha1-etm@openssh.com` ([OpenSSH PROTOCOL](https://github.com/openssh/openssh-portable/blob/60b909fb110f77c1ffd15cceb5d09b8e3f79b27e/PROTOCOL#L50)) +- `hmac-sha2-512` ([RFC 4868](https://tools.ietf.org/html/rfc4868)) +- `hmac-sha2-256` ([RFC 4868](https://tools.ietf.org/html/rfc4868)) +- `hmac-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253)) diff --git a/docs/SK_AUTH.md b/docs/SK_AUTH.md new file mode 100644 index 00000000..f06dc4a9 --- /dev/null +++ b/docs/SK_AUTH.md @@ -0,0 +1,69 @@ +# FIDO2 / Security Key Authentication + +The library supports authentication with `sk-ssh-ed25519@openssh.com` and +`sk-ecdsa-sha2-nistp256@openssh.com` keys. + +## Overview + +This library does **not** include a CTAP2 transport (USB-HID, NFC, BLE). Callers are responsible for providing their own FIDO2 stack and surfacing the resulting assertion through [`AuthHandler.onSignatureRequest`](../sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt). + +The helpers in `org.connectbot.sshlib.sk` cover the SSH wire-format bits so +callers don't have to implement OpenSSH's `PROTOCOL.u2f` themselves. + +## Implementation Example + +```kotlin +import org.connectbot.sshlib.AuthHandler +import org.connectbot.sshlib.AuthPublicKey +import org.connectbot.sshlib.sk.SkAlgorithm +import org.connectbot.sshlib.sk.SkAuthHelpers +import org.connectbot.sshlib.sk.SkSignatureBlob + +class SkAuthHandler( + private val rawPublicKey: ByteArray, // 32-byte Ed25519 pubkey from your stored SK + private val application: String, // RP id, e.g. "ssh:" + private val credentialId: ByteArray, // CTAP2 credential id + private val ctap2: MyCtap2Stack, // your CTAP2 transport +) : AuthHandler { + override suspend fun onPublicKeysNeeded() = listOf( + SkAuthHelpers.buildAuthPublicKey(SkAlgorithm.ED25519, rawPublicKey, application), + ) + + override suspend fun onSignatureRequest(key: AuthPublicKey, dataToSign: ByteArray): ByteArray { + // The CTAP2 device hashes (clientDataHash) for us — just pass dataToSign through SHA-256. + val assertion = ctap2.getAssertion( + rpId = application, + credentialId = credentialId, + clientDataHash = sha256(dataToSign), + ) + return SkSignatureBlob.pack( + algorithm = SkAlgorithm.ED25519, + rawSignature = assertion.signature, // 64-byte raw Ed25519, or DER for ECDSA-P256 + flags = assertion.flags, // 0x01 = UP, |0x04 if UV was tested + counter = assertion.counter, + ) + } + + override suspend fun onPasswordNeeded() = null + override suspend fun onKeyboardInteractivePrompt(...) = null +} + +val client = SshClient("server.example.com") +client.connect() +val result = client.authenticate("user", SkAuthHandler(...)) +``` + +## ECDSA P-256 Keys + +For ECDSA-P256 keys, pass `SkAlgorithm.ECDSA_P256` and supply the DER-encoded `SEQUENCE { INTEGER r, INTEGER s }` signature that CTAP2 returns. `SkSignatureBlob.pack()` converts it to OpenSSH's `mpint r || mpint s` form internally. + +## Public Key Decoding + +Use `SkPublicKeyDecoder` to parse public-key blobs that arrive in OpenSSH-format SK files. + +## Out of Scope + +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.) diff --git a/protocol/src/main/resources/kaitai/sk_ecdsa_p256_public_key_blob.ksy b/protocol/src/main/resources/kaitai/sk_ecdsa_p256_public_key_blob.ksy new file mode 100644 index 00000000..f660b689 --- /dev/null +++ b/protocol/src/main/resources/kaitai/sk_ecdsa_p256_public_key_blob.ksy @@ -0,0 +1,20 @@ +meta: + id: sk_ecdsa_p256_public_key_blob + endian: be + imports: + - byte_string +doc: > + OpenSSH Security Key ECDSA P-256 public key blob. + Defined in OpenSSH PROTOCOL.u2f section 3.1. +seq: + - id: curve_name_len + type: u4 + valid: 8 + - id: curve_name + contents: "nistp256" + - id: public_key + type: byte_string + doc: Uncompressed SEC1 point (0x04 || X || Y) + - id: application + type: byte_string + doc: Relying-party identifier (e.g. "ssh:") diff --git a/protocol/src/main/resources/kaitai/sk_ecdsa_p256_signature_blob.ksy b/protocol/src/main/resources/kaitai/sk_ecdsa_p256_signature_blob.ksy new file mode 100644 index 00000000..2dd0c297 --- /dev/null +++ b/protocol/src/main/resources/kaitai/sk_ecdsa_p256_signature_blob.ksy @@ -0,0 +1,19 @@ +meta: + id: sk_ecdsa_p256_signature_blob + endian: be + imports: + - byte_string + - ecdsa_signature_blob +doc: > + OpenSSH Security Key ECDSA P-256 signature blob. + Defined in OpenSSH PROTOCOL.u2f section 3.2. +seq: + - id: signature + type: ecdsa_signature_blob + doc: ECDSA signature material (mpint r || mpint s) + - id: flags + type: u1 + doc: FIDO2 device flags (e.g. 0x01 for UP) + - id: counter + type: u4 + doc: FIDO2 device signature counter diff --git a/protocol/src/main/resources/kaitai/sk_ed25519_public_key_blob.ksy b/protocol/src/main/resources/kaitai/sk_ed25519_public_key_blob.ksy new file mode 100644 index 00000000..9296c085 --- /dev/null +++ b/protocol/src/main/resources/kaitai/sk_ed25519_public_key_blob.ksy @@ -0,0 +1,15 @@ +meta: + id: sk_ed25519_public_key_blob + endian: be + imports: + - byte_string +doc: > + OpenSSH Security Key Ed25519 public key blob. + Defined in OpenSSH PROTOCOL.u2f section 3.1. +seq: + - id: public_key + type: byte_string + doc: Ed25519 public key (32 bytes) + - id: application + type: byte_string + doc: Relying-party identifier (e.g. "ssh:") diff --git a/protocol/src/main/resources/kaitai/sk_ed25519_signature_blob.ksy b/protocol/src/main/resources/kaitai/sk_ed25519_signature_blob.ksy new file mode 100644 index 00000000..40142840 --- /dev/null +++ b/protocol/src/main/resources/kaitai/sk_ed25519_signature_blob.ksy @@ -0,0 +1,18 @@ +meta: + id: sk_ed25519_signature_blob + endian: be + imports: + - byte_string +doc: > + OpenSSH Security Key Ed25519 signature blob. + Defined in OpenSSH PROTOCOL.u2f section 3.2. +seq: + - id: signature + type: byte_string + doc: Raw Ed25519 signature (64 bytes) + - id: flags + type: u1 + doc: FIDO2 device flags (e.g. 0x01 for UP) + - id: counter + type: u4 + doc: FIDO2 device signature counter diff --git a/protocol/src/main/resources/kaitai/ssh_public_key.ksy b/protocol/src/main/resources/kaitai/ssh_public_key.ksy index 7ef26e4a..6efa86f6 100644 --- a/protocol/src/main/resources/kaitai/ssh_public_key.ksy +++ b/protocol/src/main/resources/kaitai/ssh_public_key.ksy @@ -8,6 +8,8 @@ meta: - ssh_ed25519_public_key_blob - ssh_ed448_public_key_blob - ssh_rsa_public_key_blob + - sk_ed25519_public_key_blob + - sk_ecdsa_p256_public_key_blob doc-ref: RFC 4253 section 6.6 doc: > Generic SSH public key structure. The key blob format is defined @@ -30,4 +32,6 @@ seq: '"ecdsa-sha2-nistp521"': ecdsa_public_key_blob '"ssh-ed25519"': ssh_ed25519_public_key_blob '"ssh-ed448"': ssh_ed448_public_key_blob + '"sk-ssh-ed25519@openssh.com"': sk_ed25519_public_key_blob + '"sk-ecdsa-sha2-nistp256@openssh.com"': sk_ecdsa_p256_public_key_blob _: byte_string diff --git a/protocol/src/main/resources/kaitai/ssh_signature.ksy b/protocol/src/main/resources/kaitai/ssh_signature.ksy index ac40838e..522408bc 100644 --- a/protocol/src/main/resources/kaitai/ssh_signature.ksy +++ b/protocol/src/main/resources/kaitai/ssh_signature.ksy @@ -8,6 +8,8 @@ meta: - ssh_ed25519_signature_blob - ssh_ed448_signature_blob - ssh_rsa_signature_blob + - sk_ed25519_signature_blob + - sk_ecdsa_p256_signature_blob doc-ref: RFC 4253 section 6.6 doc: > Generic SSH signature structure. The signature blob format is defined @@ -32,4 +34,6 @@ seq: '"ecdsa-sha2-nistp521"': ecdsa_signature_blob '"ssh-ed25519"': ssh_ed25519_signature_blob '"ssh-ed448"': ssh_ed448_signature_blob + '"sk-ssh-ed25519@openssh.com"': sk_ed25519_signature_blob + '"sk-ecdsa-sha2-nistp256@openssh.com"': sk_ecdsa_p256_signature_blob _: byte_string diff --git a/sshlib/api.txt b/sshlib/api.txt index e9b8a769..b58db7e1 100644 --- a/sshlib/api.txt +++ b/sshlib/api.txt @@ -775,6 +775,64 @@ package org.connectbot.sshlib.client { } +package org.connectbot.sshlib.sk { + + public enum SkAlgorithm { + method @InaccessibleFromKotlin public java.lang.String getSshName(); + property public String sshName; + enum_constant public static final org.connectbot.sshlib.sk.SkAlgorithm ECDSA_P256; + enum_constant public static final org.connectbot.sshlib.sk.SkAlgorithm ED25519; + field public static final org.connectbot.sshlib.sk.SkAlgorithm.Companion Companion; + } + + public static final class SkAlgorithm.Companion { + method public org.connectbot.sshlib.sk.SkAlgorithm? fromSshName(java.lang.String name); + method public boolean isSkAlgorithm(java.lang.String name); + } + + public final class SkAuthHelpers { + method public org.connectbot.sshlib.AuthPublicKey buildAuthPublicKey(org.connectbot.sshlib.sk.SkAlgorithm algorithm, byte[] rawKey, java.lang.String application); + field public static final org.connectbot.sshlib.sk.SkAuthHelpers INSTANCE; + } + + public final class SkPublicKey { + ctor public SkPublicKey(org.connectbot.sshlib.sk.SkAlgorithm algorithm, byte[] rawKey, java.lang.String application); + method public org.connectbot.sshlib.sk.SkAlgorithm component1(); + method public byte[] component2(); + method public java.lang.String component3(); + method public org.connectbot.sshlib.sk.SkPublicKey copy(optional org.connectbot.sshlib.sk.SkAlgorithm algorithm, optional byte[] rawKey, optional java.lang.String application); + method public boolean equals(java.lang.Object? other); + method @InaccessibleFromKotlin public org.connectbot.sshlib.sk.SkAlgorithm getAlgorithm(); + method @InaccessibleFromKotlin public java.lang.String getApplication(); + method @InaccessibleFromKotlin public byte[] getRawKey(); + method public int hashCode(); + method public java.lang.String toString(); + property public org.connectbot.sshlib.sk.SkAlgorithm algorithm; + property public String application; + property public byte[] rawKey; + } + + public final class SkPublicKeyDecoder { + method public org.connectbot.sshlib.sk.SkPublicKey decode(byte[] blob); + field public static final org.connectbot.sshlib.sk.SkPublicKeyDecoder INSTANCE; + } + + public final class SkPublicKeyEncoder { + method public byte[] encode(org.connectbot.sshlib.sk.SkAlgorithm algorithm, byte[] rawKey, java.lang.String application); + field public static final org.connectbot.sshlib.sk.SkPublicKeyEncoder INSTANCE; + } + + public final class SkSignatureBlob { + method @KotlinOnly public byte[] pack(org.connectbot.sshlib.sk.SkAlgorithm algorithm, byte[] rawSignature, byte flags, kotlin.UInt counter); + property public static byte FLAG_USER_PRESENCE; + property public static byte FLAG_USER_VERIFICATION; + field public static final byte FLAG_USER_PRESENCE = 1; // 0x1 + field public static final byte FLAG_USER_VERIFICATION = 4; // 0x4 + field public static final org.connectbot.sshlib.sk.SkSignatureBlob INSTANCE; + } + +} + package org.connectbot.sshlib.transport { public interface AddressResolver { diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt index fbb94370..39f6672c 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/AuthHandler.kt @@ -39,8 +39,20 @@ interface AuthHandler { * Called only for keys the server accepted (PK_OK). Return the signature * over [dataToSign], or null to skip this key. * - * Use [SshSigning.sign] for local private keys, or return an SSH agent - * response directly. + * The returned bytes are written verbatim into the publickey + * `SSH_MSG_USERAUTH_REQUEST` signature field — the library does not + * decode or repackage them. This makes the callback a clean extension + * point for externally-signed keys. + * + * Use cases: + * - **Local private key**: call [SshSigning.sign]. + * - **SSH agent**: forward [dataToSign] to your agent and return its response. + * - **FIDO2 / Security Key** (`sk-ssh-ed25519@openssh.com`, + * `sk-ecdsa-sha2-nistp256@openssh.com`): drive your CTAP2 stack with + * `clientDataHash = SHA-256(dataToSign)`, then return + * `org.connectbot.sshlib.sk.SkSignatureBlob.pack(algorithm, rawSignature, flags, counter)`. + * See `org.connectbot.sshlib.sk.SkAuthHelpers` for the matching + * public-key blob constructor. */ suspend fun onSignatureRequest(key: AuthPublicKey, dataToSign: ByteArray): ByteArray? diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshSigning.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshSigning.kt index d4c28785..3d9dc30e 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/SshSigning.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/SshSigning.kt @@ -46,6 +46,7 @@ object SshSigning { passphrase: String?, dataToSign: ByteArray, ): ByteArray { + rejectSkAlgorithm(algorithmName) val privateKey = PrivateKeyReader.read(privateKeyData, passphrase) val sigEntry = SignatureEntry.fromSshName(algorithmName) ?: throw SshException("Unknown signature algorithm: $algorithmName") @@ -131,11 +132,23 @@ object SshSigning { keyPair: KeyPair, dataToSign: ByteArray, ): ByteArray { + rejectSkAlgorithm(algorithmName) val sigEntry = SignatureEntry.fromSshName(algorithmName) ?: throw SshException("Unknown signature algorithm: $algorithmName") return sigEntry.algorithm.sign(algorithmName, keyPair.private, dataToSign) } + private fun rejectSkAlgorithm(algorithmName: String) { + if (algorithmName.startsWith("sk-")) { + throw SshException( + "SK (FIDO2 / Security Key) algorithm \"$algorithmName\" cannot be signed locally — " + + "the private key lives on the authenticator. Drive auth through " + + "AuthHandler.onSignatureRequest(): call your CTAP2 stack for an assertion, " + + "then return SkSignatureBlob.pack(...) from org.connectbot.sshlib.sk.", + ) + } + } + /** * Encode a [PublicKey] to its raw SSH wire-format public key blob. * diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkAlgorithm.kt new file mode 100644 index 00000000..9bca429b --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkAlgorithm.kt @@ -0,0 +1,78 @@ +/* + * 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.sk + +/** + * OpenSSH FIDO2 / Security Key public-key algorithms. + * + * These algorithms are used for SSH authentication backed by a hardware + * authenticator (CTAP2 device). The library does not perform CTAP2 signing + * itself; callers integrate with their own FIDO2 stack and surface the + * resulting signature via [org.connectbot.sshlib.AuthHandler.onSignatureRequest]. + * + * See OpenSSH's `PROTOCOL.u2f` and `draft-miller-ssh-agent` for the on-wire + * formats. + */ +public enum class SkAlgorithm(public val sshName: String) { + /** Ed25519 hardware-backed key (`sk-ssh-ed25519@openssh.com`). */ + ED25519("sk-ssh-ed25519@openssh.com"), + + /** ECDSA P-256 hardware-backed key (`sk-ecdsa-sha2-nistp256@openssh.com`). */ + ECDSA_P256("sk-ecdsa-sha2-nistp256@openssh.com"), + ; + + public companion object { + /** Look up an [SkAlgorithm] by its on-wire SSH name, or `null` if unrecognised. */ + public fun fromSshName(name: String): SkAlgorithm? = entries.firstOrNull { it.sshName == name } + + /** `true` if [name] is one of the SK algorithm names handled by this library. */ + public fun isSkAlgorithm(name: String): Boolean = fromSshName(name) != null + } +} + +/** + * Decoded SK public key. + * + * @property algorithm Which SK algorithm this key uses. + * @property rawKey Algorithm-specific raw public key bytes. For [SkAlgorithm.ED25519] + * this is the 32-byte raw Ed25519 public key. For [SkAlgorithm.ECDSA_P256] this is + * the uncompressed SEC1 point (`0x04 || X(32) || Y(32)`, 65 bytes). + * @property application The relying-party identifier the key was bound to (typically + * `"ssh:"` for keys generated with `ssh-keygen -t ed25519-sk`). + */ +public data class SkPublicKey( + val algorithm: SkAlgorithm, + val rawKey: ByteArray, + val application: String, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as SkPublicKey + if (algorithm != other.algorithm) return false + if (!rawKey.contentEquals(other.rawKey)) return false + if (application != other.application) return false + return true + } + + override fun hashCode(): Int { + var result = algorithm.hashCode() + result = 31 * result + rawKey.contentHashCode() + result = 31 * result + application.hashCode() + return result + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkAuthHelpers.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkAuthHelpers.kt new file mode 100644 index 00000000..95b45f38 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkAuthHelpers.kt @@ -0,0 +1,68 @@ +/* + * 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.sk + +import org.connectbot.sshlib.AuthPublicKey + +/** + * Convenience entry points for wiring SK keys into the + * [org.connectbot.sshlib.AuthHandler] flow. + * + * Typical caller flow (caller owns the CTAP2 stack): + * + * ```kotlin + * // 1. From your stored SK key data, build an AuthPublicKey: + * val authKey = SkAuthHelpers.buildAuthPublicKey( + * algorithm = SkAlgorithm.ED25519, + * rawKey = storedRawEd25519PubKey, // 32 bytes + * application = "ssh:", // RP id the credential is bound to + * ) + * + * // 2. Return it from AuthHandler.onPublicKeysNeeded(): + * override suspend fun onPublicKeysNeeded() = listOf(authKey) + * + * // 3. In AuthHandler.onSignatureRequest(), call your CTAP2 stack with + * // clientDataHash = SHA-256(dataToSign), then return SkSignatureBlob.pack(...). + * override suspend fun onSignatureRequest(key: AuthPublicKey, dataToSign: ByteArray): ByteArray { + * val clientDataHash = sha256(dataToSign) + * val assertion = myCtap2.getAssertion(rpId = "ssh:", credentialId = ..., clientDataHash) + * return SkSignatureBlob.pack( + * algorithm = SkAlgorithm.ED25519, + * rawSignature = assertion.signature, // raw 64 bytes for Ed25519, DER for ECDSA-P256 + * flags = assertion.flags, // 0x01 = UP, |0x04 if UV + * counter = assertion.counter, + * ) + * } + * ``` + */ +public object SkAuthHelpers { + + /** + * Build an [AuthPublicKey] for an SK credential, suitable for returning from + * [org.connectbot.sshlib.AuthHandler.onPublicKeysNeeded]. + * + * The public-key blob is encoded per [SkPublicKeyEncoder]. + */ + public fun buildAuthPublicKey( + algorithm: SkAlgorithm, + rawKey: ByteArray, + application: String, + ): AuthPublicKey = AuthPublicKey( + algorithmName = algorithm.sshName, + publicKeyBlob = SkPublicKeyEncoder.encode(algorithm, rawKey, application), + ) +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkPublicKeyDecoder.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkPublicKeyDecoder.kt new file mode 100644 index 00000000..139edc57 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkPublicKeyDecoder.kt @@ -0,0 +1,93 @@ +/* + * 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.sk + +import io.kaitai.struct.ByteBufferKaitaiStream +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.protocol.SkEcdsaP256PublicKeyBlob +import org.connectbot.sshlib.protocol.SkEd25519PublicKeyBlob +import org.connectbot.sshlib.protocol.SshPublicKey + +/** + * Parses OpenSSH SK public-key wire blobs into an [SkPublicKey]. + * + * This is the inverse of [SkPublicKeyEncoder.encode]. Callers that already + * have raw key bytes can skip this; the decoder is provided so callers that + * parse `authorized_keys` or `ssh-keygen -t *-sk` files (which embed the + * pubkey blob inside an OpenSSH private-key envelope) don't have to write + * SSH-string framing logic themselves. + * + * Strict: the entire input must be consumed. + */ +public object SkPublicKeyDecoder { + + private const val ED25519_RAW_KEY_SIZE: Int = 32 + private const val P256_FIELD_SIZE: Int = 32 + private const val P256_POINT_SIZE: Int = 1 + 2 * P256_FIELD_SIZE + private const val P256_CURVE_IDENTIFIER: String = "nistp256" + + /** + * Decode an SK public-key wire blob. + * + * @throws SshException if the blob is malformed, has trailing bytes, or + * uses an algorithm name that is not one of the known SK algorithms. + */ + public fun decode(blob: ByteArray): SkPublicKey { + val stream = ByteBufferKaitaiStream(blob) + val kaitai = SshPublicKey(stream) + try { + kaitai._read() + if (!stream.isEof) { + throw SshException("Trailing bytes after SK public key blob") + } + } catch (e: Exception) { + if (e is SshException) throw e + throw SshException("Malformed SK public key blob: ${e.message}", e) + } + + val algoName = kaitai.algorithmName() + val algorithm = SkAlgorithm.fromSshName(algoName) + ?: throw SshException("Not an SK public key blob: algorithm = \"$algoName\"") + + return when (val keyBlob = kaitai.keyBlob()) { + is SkEd25519PublicKeyBlob -> { + val rawKey = keyBlob.publicKey().data() + if (rawKey.size != ED25519_RAW_KEY_SIZE) { + throw SshException( + "sk-ssh-ed25519 raw key must be $ED25519_RAW_KEY_SIZE bytes, got ${rawKey.size}", + ) + } + val application = keyBlob.application().data().toString(Charsets.UTF_8) + SkPublicKey(SkAlgorithm.ED25519, rawKey, application) + } + + is SkEcdsaP256PublicKeyBlob -> { + val ecPoint = keyBlob.publicKey().data() + if (ecPoint.size != P256_POINT_SIZE || ecPoint[0] != 0x04.toByte()) { + throw SshException( + "sk-ecdsa-sha2-nistp256 EC point must be $P256_POINT_SIZE bytes starting with 0x04, " + + "got ${ecPoint.size} bytes", + ) + } + val application = keyBlob.application().data().toString(Charsets.UTF_8) + SkPublicKey(SkAlgorithm.ECDSA_P256, ecPoint, application) + } + + else -> throw SshException("Unexpected key blob type for $algoName") + } + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkPublicKeyEncoder.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkPublicKeyEncoder.kt new file mode 100644 index 00000000..c9f81517 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkPublicKeyEncoder.kt @@ -0,0 +1,90 @@ +/* + * 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.sk + +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.crypto.encodeSshString + +/** + * Builds OpenSSH SK public-key wire blobs. + * + * The output is the byte sequence that appears in `authorized_keys` (base64-encoded + * after the algorithm name) and in the `public key blob` field of SSH publickey + * auth requests. + * + * Format per OpenSSH `PROTOCOL.u2f` §3.1: + * + * ``` + * sk-ssh-ed25519@openssh.com: + * string "sk-ssh-ed25519@openssh.com" + * string rawEd25519PublicKey (32 bytes) + * string application (e.g. "ssh:") + * + * sk-ecdsa-sha2-nistp256@openssh.com: + * string "sk-ecdsa-sha2-nistp256@openssh.com" + * string "nistp256" + * string ecPoint (uncompressed SEC1: 0x04 || X(32) || Y(32), 65 bytes) + * string application + * ``` + */ +public object SkPublicKeyEncoder { + + private const val ED25519_RAW_KEY_SIZE: Int = 32 + private const val P256_FIELD_SIZE: Int = 32 + private const val P256_POINT_SIZE: Int = 1 + 2 * P256_FIELD_SIZE + private const val P256_CURVE_IDENTIFIER: String = "nistp256" + + /** + * Build the SK public-key wire blob. + * + * @param algorithm SK algorithm. + * @param rawKey For [SkAlgorithm.ED25519] the 32-byte raw Ed25519 public key. + * For [SkAlgorithm.ECDSA_P256] the 65-byte uncompressed SEC1 point + * (`0x04 || X(32) || Y(32)`). + * @param application Relying-party identifier (e.g. `"ssh:"`). + * @throws SshException if [rawKey] has the wrong size for the algorithm, + * or if the ECDSA point is malformed. + */ + public fun encode(algorithm: SkAlgorithm, rawKey: ByteArray, application: String): ByteArray = when (algorithm) { + SkAlgorithm.ED25519 -> encodeEd25519(rawKey, application) + SkAlgorithm.ECDSA_P256 -> encodeEcdsaP256(rawKey, application) + } + + private fun encodeEd25519(rawKey: ByteArray, application: String): ByteArray { + if (rawKey.size != ED25519_RAW_KEY_SIZE) { + throw SshException( + "sk-ssh-ed25519 raw key must be $ED25519_RAW_KEY_SIZE bytes, got ${rawKey.size}", + ) + } + return encodeSshString(SkAlgorithm.ED25519.sshName.toByteArray(Charsets.US_ASCII)) + + encodeSshString(rawKey) + + encodeSshString(application.toByteArray(Charsets.UTF_8)) + } + + private fun encodeEcdsaP256(ecPoint: ByteArray, application: String): ByteArray { + if (ecPoint.size != P256_POINT_SIZE || ecPoint[0] != 0x04.toByte()) { + throw SshException( + "sk-ecdsa-sha2-nistp256 EC point must be $P256_POINT_SIZE bytes starting with 0x04, " + + "got ${ecPoint.size} bytes${if (ecPoint.isNotEmpty()) " starting with 0x%02x".format(ecPoint[0]) else ""}", + ) + } + return encodeSshString(SkAlgorithm.ECDSA_P256.sshName.toByteArray(Charsets.US_ASCII)) + + encodeSshString(P256_CURVE_IDENTIFIER.toByteArray(Charsets.US_ASCII)) + + encodeSshString(ecPoint) + + encodeSshString(application.toByteArray(Charsets.UTF_8)) + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkSignatureBlob.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkSignatureBlob.kt new file mode 100644 index 00000000..ed4012db --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/sk/SkSignatureBlob.kt @@ -0,0 +1,138 @@ +/* + * 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.sk + +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.crypto.DerReader +import org.connectbot.sshlib.crypto.encodeMpint +import org.connectbot.sshlib.crypto.encodeSshString + +/** + * 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 + * public key. The library writes it verbatim into the SSH publickey + * `USERAUTH_REQUEST` packet's signature field. + * + * Format per OpenSSH `PROTOCOL.u2f` §3.2: + * + * ``` + * sk-ssh-ed25519@openssh.com: + * string "sk-ssh-ed25519@openssh.com" + * string rawEd25519Signature (64 bytes) + * byte flags + * uint32 counter + * + * sk-ecdsa-sha2-nistp256@openssh.com: + * string "sk-ecdsa-sha2-nistp256@openssh.com" + * string sig_material (mpint r || mpint s, RFC 5656) + * byte flags + * uint32 counter + * ``` + * + * For ECDSA-P256, [pack] accepts the DER `SEQUENCE { INTEGER r, INTEGER s }` + * format that CTAP2 returns and converts it to `mpint r || mpint s` internally. + * + * What [flags] and [counter] mean (from `PROTOCOL.u2f` §3.2): the FIDO2 device + * sets `flags = SK_USER_PRESENCE_REQUIRED (0x01)` if user presence was tested + * and `| SK_USER_VERIFICATION_REQUIRED (0x04)` if user verification was also + * tested; `counter` is the device's monotonic signature counter. Both come + * from the CTAP2 GetAssertion response. + */ +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 + + /** + * Pack an SK assertion into the OpenSSH SK signature blob. + * + * @param algorithm SK algorithm. + * @param rawSignature For [SkAlgorithm.ED25519]: the raw 64-byte Ed25519 + * signature from the authenticator. For [SkAlgorithm.ECDSA_P256]: the + * DER-encoded `SEQUENCE { INTEGER r, INTEGER s }` signature from CTAP2. + * @param flags Authenticator flags byte (see [FLAG_USER_PRESENCE], + * [FLAG_USER_VERIFICATION]). + * @param counter Authenticator signature counter (big-endian uint32 on the wire). + * @return The full OpenSSH SK signature wire blob. + * @throws SshException if [rawSignature] has the wrong size or is malformed. + */ + public fun pack( + algorithm: SkAlgorithm, + rawSignature: ByteArray, + flags: Byte, + counter: UInt, + ): ByteArray { + val sigMaterial = when (algorithm) { + SkAlgorithm.ED25519 -> packEd25519Material(rawSignature) + SkAlgorithm.ECDSA_P256 -> packEcdsaP256MaterialFromDer(rawSignature) + } + return encodeSshString(algorithm.sshName.toByteArray(Charsets.US_ASCII)) + + encodeSshString(sigMaterial) + + byteArrayOf(flags) + + encodeUint32BigEndian(counter) + } + + private fun packEd25519Material(rawSignature: ByteArray): ByteArray { + if (rawSignature.size != ED25519_RAW_SIGNATURE_SIZE) { + throw SshException( + "sk-ssh-ed25519 raw signature must be $ED25519_RAW_SIGNATURE_SIZE bytes, " + + "got ${rawSignature.size}", + ) + } + return rawSignature + } + + private fun packEcdsaP256MaterialFromDer(derSignature: ByteArray): ByteArray { + if (derSignature.isEmpty()) { + throw SshException("Malformed ECDSA DER signature: empty input") + } + val (r, s) = try { + val reader = DerReader(derSignature) + val parsed = reader.readSequence { seq -> + val rInt = seq.readInteger() + val sInt = seq.readInteger() + rInt to sInt + } + reader.ensureFullyConsumed() + parsed + } catch (e: SshException) { + throw SshException("Malformed ECDSA DER signature: ${e.message}", e) + } catch (e: java.nio.BufferUnderflowException) { + throw SshException("Malformed ECDSA DER signature: truncated", e) + } catch (e: IndexOutOfBoundsException) { + throw SshException("Malformed ECDSA DER signature: truncated", e) + } + if (r.signum() < 0 || s.signum() < 0) { + throw SshException("ECDSA DER signature components must be non-negative") + } + return encodeMpint(r.toByteArray()) + encodeMpint(s.toByteArray()) + } + + private fun encodeUint32BigEndian(value: UInt): ByteArray { + val intValue = value.toInt() + return byteArrayOf( + (intValue ushr 24).toByte(), + (intValue ushr 16).toByte(), + (intValue ushr 8).toByte(), + intValue.toByte(), + ) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/SshSigningSkTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshSigningSkTest.kt new file mode 100644 index 00000000..579fdc63 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/SshSigningSkTest.kt @@ -0,0 +1,64 @@ +/* + * 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 + +import java.security.KeyPairGenerator +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SshSigningSkTest { + + @Test + fun `sign rejects sk-ssh-ed25519 with a clear message`() { + val e = assertFailsWith { + SshSigning.sign( + algorithmName = "sk-ssh-ed25519@openssh.com", + privateKeyData = "ignored", + passphrase = null, + dataToSign = byteArrayOf(0x01), + ) + } + assertTrue(e.message!!.contains("AuthHandler"), "error should point to AuthHandler API: ${e.message}") + assertTrue(e.message!!.contains("SkSignatureBlob"), "error should mention SkSignatureBlob: ${e.message}") + } + + @Test + fun `sign rejects sk-ecdsa-sha2-nistp256 with a clear message`() { + val e = assertFailsWith { + SshSigning.sign( + algorithmName = "sk-ecdsa-sha2-nistp256@openssh.com", + privateKeyData = "ignored", + passphrase = null, + dataToSign = byteArrayOf(0x01), + ) + } + assertTrue(e.message!!.contains("AuthHandler")) + } + + @Test + fun `signWithKeyPair rejects sk- algorithm names`() { + val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + assertFailsWith { + SshSigning.signWithKeyPair( + algorithmName = "sk-ssh-ed25519@openssh.com", + keyPair = keyPair, + dataToSign = byteArrayOf(0x01), + ) + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SkAuthIntegrationTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SkAuthIntegrationTest.kt new file mode 100644 index 00000000..e57233a4 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SkAuthIntegrationTest.kt @@ -0,0 +1,291 @@ +/* + * 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.client + +import kotlinx.coroutines.runBlocking +import org.connectbot.sshlib.AuthHandler +import org.connectbot.sshlib.AuthPublicKey +import org.connectbot.sshlib.AuthResult +import org.connectbot.sshlib.ConnectResult +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.KeyboardInteractiveCallback +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SshClient +import org.connectbot.sshlib.SshClientConfig +import org.connectbot.sshlib.sk.SkAlgorithm +import org.connectbot.sshlib.sk.SkAuthHelpers +import org.connectbot.sshlib.sk.SkSignatureBlob +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.output.Slf4jLogConsumer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.images.builder.ImageFromDockerfile +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.math.BigInteger +import java.security.KeyFactory +import java.security.KeyPair +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.Signature +import java.security.interfaces.ECPublicKey +import java.security.interfaces.EdECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.NamedParameterSpec +import java.util.Base64 +import java.security.PublicKey as JcaPublicKey + +/** + * End-to-end integration tests that authenticate to a real OpenSSH server + * using SK (FIDO2 / Security Key) algorithms. + * + * No real FIDO2 hardware is used. The test acts as a software SK authenticator: + * it generates an Ed25519 or ECDSA-P256 keypair at startup, computes the + * `authenticatorData || SHA-256(challenge)` payload per OpenSSH `PROTOCOL.u2f` + * §3.2, signs it with the keypair, and packages the result via + * [SkSignatureBlob.pack]. If OpenSSH accepts the auth, the wire format is + * correct. + * + * Both `sk-ssh-ed25519@openssh.com` and `sk-ecdsa-sha2-nistp256@openssh.com` + * are exercised. + */ +@Testcontainers +class SkAuthIntegrationTest { + + companion object { + private val logger = LoggerFactory.getLogger(SkAuthIntegrationTest::class.java) + private val logConsumer = Slf4jLogConsumer(logger).withPrefix("DOCKER") + + private const val USERNAME = "testuser" + private const val OPENSSH_VERSION = "9.9p2" + private const val APPLICATION = "ssh:test" + + // Reuse the shared openssh-server Dockerfile (same as other integration tests). + @Container + @JvmStatic + val opensshContainer: GenericContainer<*> = GenericContainer( + ImageFromDockerfile("openssh-sftp-test", false) + .withFileFromClasspath(".", "openssh-server") + .withFileFromClasspath("test_rsa.pub", "keys/rsa_unencrypted.pub") + .withBuildArg("OPENSSH_VERSION", OPENSSH_VERSION), + ) + .withExposedPorts(22) + .withLogConsumer(logConsumer) + .waitingFor(Wait.forLogMessage(".*Server listening.*", 1)) + + private val ed25519KeyPair: KeyPair by lazy { generateEd25519KeyPair() } + private val ecdsaP256KeyPair: KeyPair by lazy { generateEcdsaP256KeyPair() } + private val containerSetUp = java.util.concurrent.atomic.AtomicBoolean(false) + + /** + * Append SK authorized_keys entries for our generated test keypairs and + * widen `PubkeyAcceptedAlgorithms` to include `sk-*`. Idempotent — the + * first invocation does the work; subsequent invocations are cheap. + */ + private fun setUpContainerForSk() { + if (!containerSetUp.compareAndSet(false, true)) return + + // Allow sk-* publickey algorithms server-side (default in 9.9 includes them, + // but the Dockerfile's existing config tweaks may have narrowed it). + val sshdExtraConfig = "PubkeyAcceptedAlgorithms +sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com" + opensshContainer.execInContainer("sh", "-c", "echo '$sshdExtraConfig' >> /etc/ssh/sshd_config && killall -HUP sshd") + + 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 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) { + "Failed to install SK authorized_keys: stdout=${result.stdout} stderr=${result.stderr}" + } + } + + private fun authorizedKeysLine(algorithm: SkAlgorithm, rawKey: ByteArray, comment: String): String { + val blob = org.connectbot.sshlib.sk.SkPublicKeyEncoder.encode(algorithm, rawKey, APPLICATION) + val base64 = Base64.getEncoder().encodeToString(blob) + return "${algorithm.sshName} $base64 $comment" + } + + private fun generateEd25519KeyPair(): KeyPair { + val gen = java.security.KeyPairGenerator.getInstance("Ed25519") + gen.initialize(NamedParameterSpec.ED25519, SecureRandom()) + return gen.generateKeyPair() + } + + private fun generateEcdsaP256KeyPair(): KeyPair { + val gen = java.security.KeyPairGenerator.getInstance("EC") + gen.initialize(ECGenParameterSpec("secp256r1"), SecureRandom()) + return gen.generateKeyPair() + } + + /** Extract the raw 32-byte Ed25519 public key from a JCA EdECPublicKey. */ + private fun ed25519PublicKeyRaw(jcaPub: JcaPublicKey): ByteArray { + val edPub = jcaPub as EdECPublicKey + val point = edPub.point + // JCA returns the y-coordinate as BigInteger; the encoding is little-endian 32 bytes + // with the sign bit (xOdd) in the high bit of the last byte. + val yBytes = point.y.toByteArray() + // Reverse to little-endian and pad/trim to 32 bytes + val little = ByteArray(32) + val reversed = yBytes.reversedArray() + System.arraycopy(reversed, 0, little, 0, minOf(reversed.size, 32)) + if (point.isXOdd) { + little[31] = (little[31].toInt() or 0x80).toByte() + } + return little + } + + /** Extract the uncompressed SEC1 EC point (`0x04 || X(32) || Y(32)`) from a JCA ECPublicKey. */ + private fun ecdsaP256PublicKeyPoint(jcaPub: JcaPublicKey): ByteArray { + val ecPub = jcaPub as ECPublicKey + val x = padTo32(ecPub.w.affineX.toByteArray()) + val y = padTo32(ecPub.w.affineY.toByteArray()) + return byteArrayOf(0x04) + x + y + } + + private fun padTo32(bytes: ByteArray): ByteArray { + if (bytes.size == 32) return bytes + if (bytes.size > 32) return bytes.copyOfRange(bytes.size - 32, bytes.size) + val out = ByteArray(32) + System.arraycopy(bytes, 0, out, 32 - bytes.size, bytes.size) + return out + } + } + + private val acceptAllVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + } + + // ---------------------- Ed25519 SK ---------------------- + + @Test + fun `authenticates with sk-ssh-ed25519@openssh dot com via AuthHandler`() = runBlocking { + setUpContainerForSk() + + val handler = SoftwareSkAuthHandler( + algorithm = SkAlgorithm.ED25519, + rawPublicKey = ed25519PublicKeyRaw(ed25519KeyPair.public), + privateKey = ed25519KeyPair.private, + ) + val result = connectAndAuthenticate(handler) + assertTrue(result is AuthResult.Success, "SK Ed25519 auth should succeed, got: $result") + } + + // ---------------------- ECDSA-P256 SK ---------------------- + + @Test + fun `authenticates with sk-ecdsa-sha2-nistp256@openssh dot com via AuthHandler`() = runBlocking { + setUpContainerForSk() + + val handler = SoftwareSkAuthHandler( + algorithm = SkAlgorithm.ECDSA_P256, + rawPublicKey = ecdsaP256PublicKeyPoint(ecdsaP256KeyPair.public), + privateKey = ecdsaP256KeyPair.private, + ) + val result = connectAndAuthenticate(handler) + assertTrue(result is AuthResult.Success, "SK ECDSA-P256 auth should succeed, got: $result") + } + + private suspend fun connectAndAuthenticate(handler: AuthHandler): AuthResult { + val host = opensshContainer.host + val port = opensshContainer.getMappedPort(22) + + val config = SshClientConfig { + this.host = host + this.port = port + this.hostKeyVerifier = acceptAllVerifier + } + val client = SshClient(config) + + val connectResult = client.connect() + assertTrue(connectResult is ConnectResult.Success, "Should connect, got: $connectResult") + + return try { + client.authenticate(USERNAME, handler) + } finally { + client.disconnect() + } + } + + /** + * Simulates a FIDO2 SK authenticator using local JCA keys. Per OpenSSH + * `PROTOCOL.u2f` §3.2, the device-signed payload is: + * + * ``` + * SHA-256(application) || flags || counter (big-endian uint32) || SHA-256(challenge) + * ``` + * + * where `challenge` is the SSH `dataToSign` blob. + */ + private class SoftwareSkAuthHandler( + private val algorithm: SkAlgorithm, + private val rawPublicKey: ByteArray, + private val privateKey: PrivateKey, + ) : AuthHandler { + + override suspend fun onPublicKeysNeeded(): List = listOf(SkAuthHelpers.buildAuthPublicKey(algorithm, rawPublicKey, APPLICATION)) + + override suspend fun onSignatureRequest(key: AuthPublicKey, dataToSign: ByteArray): ByteArray? { + val flags: Byte = SkSignatureBlob.FLAG_USER_PRESENCE + val counter: UInt = 1u + + val rpIdHash = sha256(APPLICATION.toByteArray(Charsets.UTF_8)) + val challengeHash = sha256(dataToSign) + val authData = rpIdHash + byteArrayOf(flags) + uint32BigEndian(counter) + challengeHash + + val rawSignature = when (algorithm) { + SkAlgorithm.ED25519 -> { + val signer = Signature.getInstance("Ed25519") + signer.initSign(privateKey) + signer.update(authData) + signer.sign() + } + + SkAlgorithm.ECDSA_P256 -> { + val signer = Signature.getInstance("SHA256withECDSA") + signer.initSign(privateKey) + signer.update(authData) + signer.sign() // DER-encoded SEQUENCE { INTEGER r, INTEGER s } + } + } + + return SkSignatureBlob.pack(algorithm, rawSignature, flags, counter) + } + + override suspend fun onPasswordNeeded(): String? = null + override suspend fun onKeyboardInteractivePrompt( + name: String, + instruction: String, + prompts: List, + ): List? = null + + private fun sha256(bytes: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(bytes) + + private fun uint32BigEndian(value: UInt): ByteArray { + val i = value.toInt() + return byteArrayOf( + (i ushr 24).toByte(), + (i ushr 16).toByte(), + (i ushr 8).toByte(), + i.toByte(), + ) + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkAlgorithmTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkAlgorithmTest.kt new file mode 100644 index 00000000..168b7874 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkAlgorithmTest.kt @@ -0,0 +1,57 @@ +/* + * 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.sk + +import nl.jqno.equalsverifier.EqualsVerifier +import nl.jqno.equalsverifier.Warning +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SkAlgorithmTest { + + @Test + fun `fromSshName matches both algorithms`() { + assertEquals(SkAlgorithm.ED25519, SkAlgorithm.fromSshName("sk-ssh-ed25519@openssh.com")) + assertEquals(SkAlgorithm.ECDSA_P256, SkAlgorithm.fromSshName("sk-ecdsa-sha2-nistp256@openssh.com")) + } + + @Test + fun `fromSshName rejects non-SK names`() { + assertNull(SkAlgorithm.fromSshName("ssh-ed25519")) + assertNull(SkAlgorithm.fromSshName("rsa-sha2-256")) + assertNull(SkAlgorithm.fromSshName("")) + } + + @Test + fun `isSkAlgorithm classifies names`() { + assertTrue(SkAlgorithm.isSkAlgorithm("sk-ssh-ed25519@openssh.com")) + assertTrue(SkAlgorithm.isSkAlgorithm("sk-ecdsa-sha2-nistp256@openssh.com")) + assertFalse(SkAlgorithm.isSkAlgorithm("ssh-ed25519")) + // Other SK variants (e.g. webauthn) intentionally not handled by this helper + assertFalse(SkAlgorithm.isSkAlgorithm("webauthn-sk-ecdsa-sha2-nistp256@openssh.com")) + } + + @Test + fun `SkPublicKey respects equals and hashCode contract`() { + EqualsVerifier.forClass(SkPublicKey::class.java) + .suppress(Warning.SURROGATE_KEY) + .verify() + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkAuthHelpersTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkAuthHelpersTest.kt new file mode 100644 index 00000000..c3d73ea4 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkAuthHelpersTest.kt @@ -0,0 +1,45 @@ +/* + * 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.sk + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class SkAuthHelpersTest { + + @Test + fun `buildAuthPublicKey sets algorithm name and encodes blob`() { + val rawKey = ByteArray(32) { it.toByte() } + val authKey = SkAuthHelpers.buildAuthPublicKey(SkAlgorithm.ED25519, rawKey, "ssh:") + + assertEquals("sk-ssh-ed25519@openssh.com", authKey.algorithmName) + val expectedBlob = SkPublicKeyEncoder.encode(SkAlgorithm.ED25519, rawKey, "ssh:") + assertContentEquals(expectedBlob, authKey.publicKeyBlob) + } + + @Test + fun `buildAuthPublicKey works for ECDSA-P256`() { + val ecPoint = ByteArray(65).also { it[0] = 0x04 } + val authKey = SkAuthHelpers.buildAuthPublicKey(SkAlgorithm.ECDSA_P256, ecPoint, "ssh:rp") + assertEquals("sk-ecdsa-sha2-nistp256@openssh.com", authKey.algorithmName) + assertContentEquals( + SkPublicKeyEncoder.encode(SkAlgorithm.ECDSA_P256, ecPoint, "ssh:rp"), + authKey.publicKeyBlob, + ) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkPublicKeyDecoderTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkPublicKeyDecoderTest.kt new file mode 100644 index 00000000..6610ea48 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkPublicKeyDecoderTest.kt @@ -0,0 +1,135 @@ +/* + * 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.sk + +import org.connectbot.sshlib.SshException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SkPublicKeyDecoderTest { + + @Test + fun `round-trips Ed25519 SK public key`() { + val original = SkPublicKey( + algorithm = SkAlgorithm.ED25519, + rawKey = ByteArray(32) { it.toByte() }, + application = "ssh:", + ) + val blob = SkPublicKeyEncoder.encode(original.algorithm, original.rawKey, original.application) + val decoded = SkPublicKeyDecoder.decode(blob) + assertEquals(original, decoded) + } + + @Test + fun `round-trips ECDSA-P256 SK public key`() { + val ecPoint = ByteArray(65).also { + it[0] = 0x04 + for (i in 1..64) it[i] = i.toByte() + } + val original = SkPublicKey( + algorithm = SkAlgorithm.ECDSA_P256, + rawKey = ecPoint, + application = "ssh:my-rp", + ) + val blob = SkPublicKeyEncoder.encode(original.algorithm, original.rawKey, original.application) + val decoded = SkPublicKeyDecoder.decode(blob) + assertEquals(original.algorithm, decoded.algorithm) + assertContentEquals(original.rawKey, decoded.rawKey) + assertEquals(original.application, decoded.application) + } + + @Test + fun `rejects unknown algorithm name`() { + val blob = sshString("ssh-ed25519".toByteArray()) + sshString(ByteArray(32)) + val e = assertFailsWith { SkPublicKeyDecoder.decode(blob) } + assert(e.message!!.contains("ssh-ed25519")) { "expected algo name in error: ${e.message}" } + } + + @Test + fun `rejects truncated blob`() { + val blob = sshString("sk-ssh-ed25519@openssh.com".toByteArray()) + sshString(ByteArray(16)) // wrong size + assertFailsWith { SkPublicKeyDecoder.decode(blob) } + } + + @Test + fun `rejects trailing bytes`() { + val good = SkPublicKeyEncoder.encode(SkAlgorithm.ED25519, ByteArray(32), "ssh:") + val withTrailing = good + byteArrayOf(0x01, 0x02, 0x03) + assertFailsWith { SkPublicKeyDecoder.decode(withTrailing) } + } + + @Test + fun `rejects ECDSA blob with wrong curve identifier`() { + val ecPoint = ByteArray(65).also { it[0] = 0x04 } + val blob = sshString("sk-ecdsa-sha2-nistp256@openssh.com".toByteArray()) + + sshString("nistp384".toByteArray()) + + sshString(ecPoint) + + sshString("ssh:".toByteArray()) + val e = assertFailsWith { SkPublicKeyDecoder.decode(blob) } + assert(e.message!!.contains("Malformed")) { + "expected curve mismatch error: ${e.message}" + } + } + + @Test + fun `rejects Ed25519 blob with wrong raw key size`() { + // Inner blob expects 32 bytes for Ed25519. We'll give it 31. + val blob = sshString("sk-ssh-ed25519@openssh.com".toByteArray()) + + sshString(ByteArray(31)) + + sshString("ssh:".toByteArray()) + assertFailsWith { SkPublicKeyDecoder.decode(blob) } + } + + @Test + fun `rejects ECDSA blob with wrong point size`() { + val blob = sshString("sk-ecdsa-sha2-nistp256@openssh.com".toByteArray()) + + sshString("nistp256".toByteArray()) + + sshString(ByteArray(64)) + // should be 65 + sshString("ssh:".toByteArray()) + assertFailsWith { SkPublicKeyDecoder.decode(blob) } + } + + @Test + fun `rejects ECDSA blob with wrong point prefix`() { + val ecPoint = ByteArray(65).also { it[0] = 0x03 } // should be 0x04 + val blob = sshString("sk-ecdsa-sha2-nistp256@openssh.com".toByteArray()) + + sshString("nistp256".toByteArray()) + + sshString(ecPoint) + + sshString("ssh:".toByteArray()) + assertFailsWith { SkPublicKeyDecoder.decode(blob) } + } + + @Test + fun `SkPublicKey coverage for data class methods`() { + val key = SkPublicKey(SkAlgorithm.ED25519, ByteArray(32), "ssh:") + assert(key.toString().contains("ED25519")) + assertEquals(key.hashCode(), SkPublicKey(SkAlgorithm.ED25519, ByteArray(32), "ssh:").hashCode()) + assert(key != SkPublicKey(SkAlgorithm.ECDSA_P256, ByteArray(65), "ssh:")) + } + + private fun sshString(bytes: ByteArray): ByteArray { + val len = bytes.size + return byteArrayOf( + (len ushr 24).toByte(), + (len ushr 16).toByte(), + (len ushr 8).toByte(), + len.toByte(), + ) + bytes + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkPublicKeyEncoderTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkPublicKeyEncoderTest.kt new file mode 100644 index 00000000..d9bb7ccc --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkPublicKeyEncoderTest.kt @@ -0,0 +1,120 @@ +/* + * 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.sk + +import org.connectbot.sshlib.SshException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class SkPublicKeyEncoderTest { + + @Test + fun `encodes sk-ssh-ed25519 blob byte-exact`() { + val rawKey = ByteArray(32) { i -> (0x40 + i).toByte() } + val application = "ssh:" + + val blob = SkPublicKeyEncoder.encode(SkAlgorithm.ED25519, rawKey, application) + + // string "sk-ssh-ed25519@openssh.com" || string rawKey(32) || string "ssh:" + val expected = sshString("sk-ssh-ed25519@openssh.com".toByteArray()) + + sshString(rawKey) + + sshString("ssh:".toByteArray()) + assertContentEquals(expected, blob) + + // Spot-check the byte layout explicitly so a future format change is loud. + // 4-byte length prefix of "sk-ssh-ed25519@openssh.com" = 26 = 0x1a + assertEquals(0x00.toByte(), blob[0]) + assertEquals(0x00.toByte(), blob[1]) + assertEquals(0x00.toByte(), blob[2]) + assertEquals(0x1a.toByte(), blob[3]) + // Algorithm string starts at offset 4 + assertEquals('s'.code.toByte(), blob[4]) + // After 26-byte algorithm string: 4-byte length = 32 = 0x20 + assertEquals(0x00.toByte(), blob[30]) + assertEquals(0x00.toByte(), blob[31]) + assertEquals(0x00.toByte(), blob[32]) + assertEquals(0x20.toByte(), blob[33]) + // Raw key first byte = 0x40 + assertEquals(0x40.toByte(), blob[34]) + } + + @Test + fun `encodes sk-ecdsa-sha2-nistp256 blob byte-exact`() { + val ecPoint = ByteArray(65).also { + it[0] = 0x04 // uncompressed marker + for (i in 1..32) it[i] = (0x10 + i).toByte() // X + for (i in 1..32) it[32 + i] = (0x80 + i).toByte() // Y + } + val application = "ssh:" + + val blob = SkPublicKeyEncoder.encode(SkAlgorithm.ECDSA_P256, ecPoint, application) + + val expected = sshString("sk-ecdsa-sha2-nistp256@openssh.com".toByteArray()) + + sshString("nistp256".toByteArray()) + + sshString(ecPoint) + + sshString("ssh:".toByteArray()) + assertContentEquals(expected, blob) + } + + @Test + fun `rejects wrong-sized Ed25519 raw key`() { + assertFailsWith { + SkPublicKeyEncoder.encode(SkAlgorithm.ED25519, ByteArray(31), "ssh:") + } + assertFailsWith { + SkPublicKeyEncoder.encode(SkAlgorithm.ED25519, ByteArray(33), "ssh:") + } + } + + @Test + fun `rejects ECDSA point not in uncompressed form`() { + // Compressed point (starts with 0x02 or 0x03) is rejected + val compressed = ByteArray(33).also { it[0] = 0x02 } + assertFailsWith { + SkPublicKeyEncoder.encode(SkAlgorithm.ECDSA_P256, compressed, "ssh:") + } + // Wrong-sized uncompressed point is rejected + val wrongSize = ByteArray(64).also { it[0] = 0x04 } + assertFailsWith { + SkPublicKeyEncoder.encode(SkAlgorithm.ECDSA_P256, wrongSize, "ssh:") + } + } + + @Test + fun `handles non-ASCII application string`() { + val rawKey = ByteArray(32) + val application = "ssh:café" + + val blob = SkPublicKeyEncoder.encode(SkAlgorithm.ED25519, rawKey, application) + + // Application is UTF-8 encoded; round-trip via decoder confirms it survives. + val decoded = SkPublicKeyDecoder.decode(blob) + assertEquals(application, decoded.application) + } + + private fun sshString(bytes: ByteArray): ByteArray { + val len = bytes.size + return byteArrayOf( + (len ushr 24).toByte(), + (len ushr 16).toByte(), + (len ushr 8).toByte(), + len.toByte(), + ) + bytes + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkSignatureBlobTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkSignatureBlobTest.kt new file mode 100644 index 00000000..5327c2ba --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/sk/SkSignatureBlobTest.kt @@ -0,0 +1,204 @@ +/* + * 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.sk + +import org.connectbot.sshlib.SshException +import java.math.BigInteger +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith + +class SkSignatureBlobTest { + + // ---------------------- Ed25519 ---------------------- + + @Test + fun `packs Ed25519 signature blob byte-exact`() { + val rawSig = ByteArray(64) { i -> (0xA0 + (i and 0x0f)).toByte() } + val flags: Byte = SkSignatureBlob.FLAG_USER_PRESENCE + val counter: UInt = 0x12345678u + + val blob = SkSignatureBlob.pack(SkAlgorithm.ED25519, rawSig, flags, counter) + + val expected = sshString("sk-ssh-ed25519@openssh.com".toByteArray()) + + sshString(rawSig) + + byteArrayOf(flags) + + byteArrayOf(0x12, 0x34, 0x56, 0x78) + assertContentEquals(expected, blob) + } + + @Test + fun `Ed25519 flags accumulate UP and UV bits`() { + val rawSig = ByteArray(64) + val combinedFlags = (SkSignatureBlob.FLAG_USER_PRESENCE.toInt() or SkSignatureBlob.FLAG_USER_VERIFICATION.toInt()).toByte() + val blob = SkSignatureBlob.pack(SkAlgorithm.ED25519, rawSig, combinedFlags, 1u) + // Flag byte is at: 4 + 26 (algo string len-prefix + algo) + 4 + 64 (sig string len-prefix + sig) = 98 + val flagOffset = 4 + 26 + 4 + 64 + assertContentEquals(byteArrayOf(0x05), blob.copyOfRange(flagOffset, flagOffset + 1)) + } + + @Test + fun `rejects wrong-sized Ed25519 raw signature`() { + assertFailsWith { + SkSignatureBlob.pack(SkAlgorithm.ED25519, ByteArray(63), 0x01, 1u) + } + assertFailsWith { + SkSignatureBlob.pack(SkAlgorithm.ED25519, ByteArray(65), 0x01, 1u) + } + } + + @Test + fun `Ed25519 counter wraps at uint32 boundary correctly`() { + val rawSig = ByteArray(64) + val blob = SkSignatureBlob.pack(SkAlgorithm.ED25519, rawSig, 0x01, UInt.MAX_VALUE) + // Last 4 bytes = 0xFF 0xFF 0xFF 0xFF + assertContentEquals(byteArrayOf(-1, -1, -1, -1), blob.copyOfRange(blob.size - 4, blob.size)) + } + + // ---------------------- ECDSA-P256 ---------------------- + + @Test + fun `packs ECDSA-P256 with small r and s (no high-bit padding)`() { + // DER: SEQUENCE { INTEGER 0x12, INTEGER 0x34 } = 30 06 02 01 12 02 01 34 + val der = byteArrayOf(0x30, 0x06, 0x02, 0x01, 0x12, 0x02, 0x01, 0x34) + + val blob = SkSignatureBlob.pack(SkAlgorithm.ECDSA_P256, der, 0x01, 0x00000001u) + + // sig_material = mpint(0x12) || mpint(0x34) + // = (00 00 00 01 12) || (00 00 00 01 34) + // = 00 00 00 01 12 00 00 00 01 34 (10 bytes) + val sigMaterial = byteArrayOf( + 0x00, 0x00, 0x00, 0x01, 0x12, + 0x00, 0x00, 0x00, 0x01, 0x34, + ) + val expected = sshString("sk-ecdsa-sha2-nistp256@openssh.com".toByteArray()) + + sshString(sigMaterial) + + byteArrayOf(0x01) + + byteArrayOf(0x00, 0x00, 0x00, 0x01) + assertContentEquals(expected, blob) + } + + @Test + fun `packs ECDSA-P256 where r needs mpint zero-padding (high bit set)`() { + // r = 32 bytes with high bit set => DER adds leading 0x00 to keep it positive (33 bytes total) + // s = 32 bytes with high bit clear => DER body is exactly 32 bytes + val rBytes = ByteArray(32) { i -> if (i == 0) 0x80.toByte() else (0x01 + i).toByte() } + val sBytes = ByteArray(32) { i -> if (i == 0) 0x01 else (0x10 + i).toByte() } + + val rDerBody = byteArrayOf(0x00) + rBytes // 33 bytes + val sDerBody = sBytes // 32 bytes + val der = byteArrayOf(0x30, (rDerBody.size + 2 + sDerBody.size + 2).toByte()) + + byteArrayOf(0x02, rDerBody.size.toByte()) + rDerBody + + byteArrayOf(0x02, sDerBody.size.toByte()) + sDerBody + + val blob = SkSignatureBlob.pack(SkAlgorithm.ECDSA_P256, der, 0x01, 7u) + + // mpint(r) needs leading 0x00 because r's high bit is set: length-prefix=33, body=00||rBytes + // mpint(s) has high bit clear: length-prefix=32, body=sBytes + val expectedSigMaterial = byteArrayOf(0x00, 0x00, 0x00, 0x21, 0x00) + rBytes + + byteArrayOf(0x00, 0x00, 0x00, 0x20) + sBytes + val expected = sshString("sk-ecdsa-sha2-nistp256@openssh.com".toByteArray()) + + sshString(expectedSigMaterial) + + byteArrayOf(0x01) + + byteArrayOf(0x00, 0x00, 0x00, 0x07) + assertContentEquals(expected, blob) + } + + @Test + fun `packs ECDSA-P256 mpint conversion is round-trip-stable via BigInteger`() { + // Build a DER with two random-ish 256-bit values, pack, then parse the mpints + // back out and confirm BigInteger equality. + val r = BigInteger("0123456789ABCDEFFEDCBA98765432100123456789ABCDEFFEDCBA9876543210", 16) + val s = BigInteger("FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210", 16) + val der = makeDerEcdsaSignature(r, s) + + val blob = SkSignatureBlob.pack(SkAlgorithm.ECDSA_P256, der, 0x05, 99u) + + // Skip past algo-string to the sig string (which contains mpint r || mpint s) + val algoLen = "sk-ecdsa-sha2-nistp256@openssh.com".length + val sigStringOffset = 4 + algoLen + val sigStringLen = readUint32(blob, sigStringOffset) + val sigMaterial = blob.copyOfRange(sigStringOffset + 4, sigStringOffset + 4 + sigStringLen) + + // Parse mpint r, mpint s + val rLen = readUint32(sigMaterial, 0) + val rOut = BigInteger(1, sigMaterial.copyOfRange(4, 4 + rLen)) + val sLen = readUint32(sigMaterial, 4 + rLen) + val sOut = BigInteger(1, sigMaterial.copyOfRange(4 + rLen + 4, 4 + rLen + 4 + sLen)) + + kotlin.test.assertEquals(r, rOut) + kotlin.test.assertEquals(s, sOut) + } + + @Test + fun `rejects malformed DER for ECDSA`() { + // Empty bytes + assertFailsWith { + SkSignatureBlob.pack(SkAlgorithm.ECDSA_P256, ByteArray(0), 0x01, 1u) + } + // Not a SEQUENCE + assertFailsWith { + SkSignatureBlob.pack(SkAlgorithm.ECDSA_P256, byteArrayOf(0x04, 0x00), 0x01, 1u) + } + // SEQUENCE missing one INTEGER + assertFailsWith { + SkSignatureBlob.pack( + SkAlgorithm.ECDSA_P256, + byteArrayOf(0x30, 0x03, 0x02, 0x01, 0x12), + 0x01, + 1u, + ) + } + } + + // ---------------------- helpers ---------------------- + + /** Build DER `SEQUENCE { INTEGER r, INTEGER s }` from two non-negative BigIntegers. */ + private fun makeDerEcdsaSignature(r: BigInteger, s: BigInteger): ByteArray { + val rTlv = derInteger(r) + val sTlv = derInteger(s) + val content = rTlv + sTlv + return byteArrayOf(0x30) + derLength(content.size) + content + } + + private fun derInteger(value: BigInteger): ByteArray { + val body = value.toByteArray() // BigInteger.toByteArray() already adds leading 0x00 if high bit set + return byteArrayOf(0x02) + derLength(body.size) + body + } + + private fun derLength(length: Int): ByteArray { + if (length < 0x80) return byteArrayOf(length.toByte()) + // Short-form sufficient for our test sizes + if (length < 0x100) return byteArrayOf(0x81.toByte(), length.toByte()) + return byteArrayOf(0x82.toByte(), (length ushr 8).toByte(), length.toByte()) + } + + private fun sshString(bytes: ByteArray): ByteArray { + val len = bytes.size + return byteArrayOf( + (len ushr 24).toByte(), + (len ushr 16).toByte(), + (len ushr 8).toByte(), + len.toByte(), + ) + bytes + } + + private fun readUint32(buf: ByteArray, offset: Int): Int = ((buf[offset].toInt() and 0xff) shl 24) or + ((buf[offset + 1].toInt() and 0xff) shl 16) or + ((buf[offset + 2].toInt() and 0xff) shl 8) or + (buf[offset + 3].toInt() and 0xff) +}