diff --git a/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java b/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java index f16fce18..4bd5e9ac 100644 --- a/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java +++ b/src/main/java/com/trilead/ssh2/auth/AuthenticationManager.java @@ -35,6 +35,7 @@ import com.trilead.ssh2.signature.Ed25519Verify; import com.trilead.ssh2.signature.RSASHA1Verify; import com.trilead.ssh2.signature.SSHSignature; +import com.trilead.ssh2.signature.SkPublicKey; import com.trilead.ssh2.transport.MessageHandler; import com.trilead.ssh2.transport.TransportManager; @@ -339,6 +340,45 @@ else if ("EdDSA".equals(publicKey.getAlgorithm())) tm.sendMessage(ua.getPayload()); } + else if (publicKey instanceof SkPublicKey) + { + // FIDO2 Security Key (SK) authentication + // SK keys require external signing via SignatureProxy + if (signatureProxy == null) + { + throw new IOException("SK key authentication requires a SignatureProxy for signing."); + } + + SkPublicKey skPublicKey = (SkPublicKey) publicKey; + final String algo = skPublicKey.getSshKeyType(); + + // Get the encoded public key (includes key type, key data, and application) + byte[] pk_enc = skPublicKey.getEncoded(); + + byte[] msg = this.generatePublicKeyUserAuthenticationRequest(user, algo, pk_enc); + + // Determine the hash algorithm based on key type + // sk-ssh-ed25519@openssh.com uses SHA512 (same as Ed25519) + // sk-ecdsa-sha2-nistp256@openssh.com uses SHA256 + String hashAlgorithm; + if (algo.contains("ed25519")) + { + hashAlgorithm = SignatureProxy.SHA512; + } + else + { + hashAlgorithm = SignatureProxy.SHA256; + } + + // The SignatureProxy.sign() for SK keys must return the complete + // signature blob including flags and counter, not just the raw signature + byte[] sk_sig_enc = signatureProxy.sign(msg, hashAlgorithm); + + PacketUserauthRequestPublicKey ua = new PacketUserauthRequestPublicKey("ssh-connection", user, + algo, pk_enc, sk_sig_enc); + + tm.sendMessage(ua.getPayload()); + } else { throw new IOException("Unknown public key type."); diff --git a/src/main/java/com/trilead/ssh2/signature/SkPublicKey.java b/src/main/java/com/trilead/ssh2/signature/SkPublicKey.java new file mode 100644 index 00000000..8e90f885 --- /dev/null +++ b/src/main/java/com/trilead/ssh2/signature/SkPublicKey.java @@ -0,0 +1,43 @@ + +package com.trilead.ssh2.signature; + +import java.security.PublicKey; + +/** + * Interface for FIDO2 Security Key (SK) public keys used in SSH authentication. + * + * SK keys are hardware-backed keys where the private key never leaves the device. + * The signature format includes additional fields (flags, counter) beyond the + * raw cryptographic signature. + * + * Implementations should provide: + * - sk-ssh-ed25519@openssh.com for Ed25519-based SK keys + * - sk-ecdsa-sha2-nistp256@openssh.com for ECDSA P-256 SK keys + */ +public interface SkPublicKey extends PublicKey { + + /** + * Get the SSH key type identifier. + * + * @return The key type string, e.g., "sk-ssh-ed25519@openssh.com" or + * "sk-ecdsa-sha2-nistp256@openssh.com" + */ + String getSshKeyType(); + + /** + * Get the application ID (relying party ID) for this key. + * Typically "ssh:" for SSH authentication. + * + * @return The application ID string + */ + String getApplication(); + + /** + * Get the underlying key data (without the key type prefix). + * For Ed25519, this is the 32-byte public key. + * For ECDSA, this is the uncompressed EC point. + * + * @return The raw key bytes + */ + byte[] getKeyData(); +} diff --git a/src/test/java/com/trilead/ssh2/auth/AuthenticationManagerSkKeyTest.java b/src/test/java/com/trilead/ssh2/auth/AuthenticationManagerSkKeyTest.java new file mode 100644 index 00000000..89003091 --- /dev/null +++ b/src/test/java/com/trilead/ssh2/auth/AuthenticationManagerSkKeyTest.java @@ -0,0 +1,305 @@ +package com.trilead.ssh2.auth; + +import com.trilead.ssh2.ExtensionInfo; +import com.trilead.ssh2.packets.TypesWriter; +import com.trilead.ssh2.signature.SkPublicKey; +import com.trilead.ssh2.transport.TransportManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.HashSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; + +/** + * Tests for SK (Security Key) public key authentication in AuthenticationManager. + */ +@ExtendWith(MockitoExtension.class) +public class AuthenticationManagerSkKeyTest { + + private static final String SK_ED25519_KEY_TYPE = "sk-ssh-ed25519@openssh.com"; + private static final String SK_ECDSA_KEY_TYPE = "sk-ecdsa-sha2-nistp256@openssh.com"; + private static final String DEFAULT_APPLICATION = "ssh:"; + private static final String TEST_USER = "testuser"; + private static final byte[] TEST_SESSION_ID = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + private static final byte[] TEST_KEY_DATA = new byte[] { + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20 + }; + private static final byte[] TEST_SIGNATURE = new byte[] { 0x55, 0x66, 0x77, 0x08 }; + + @Mock + private TransportManager tm; + + @Mock + private ExtensionInfo extensionInfo; + + private AuthenticationManager authManager; + + /** + * Test implementation of SkPublicKey for unit testing. + */ + static class TestSkPublicKey implements SkPublicKey { + private final String keyType; + private final String application; + private final byte[] keyData; + + TestSkPublicKey(String keyType, String application, byte[] keyData) { + this.keyType = keyType; + this.application = application; + this.keyData = keyData.clone(); + } + + @Override + public String getSshKeyType() { + return keyType; + } + + @Override + public String getApplication() { + return application; + } + + @Override + public byte[] getKeyData() { + return keyData.clone(); + } + + @Override + public String getAlgorithm() { + return keyType; + } + + @Override + public String getFormat() { + return "SSH"; + } + + @Override + public byte[] getEncoded() { + TypesWriter tw = new TypesWriter(); + tw.writeString(keyType); + tw.writeString(keyData, 0, keyData.length); + tw.writeString(application); + return tw.getBytes(); + } + } + + /** + * Test SignatureProxy that records the hash algorithm used for signing. + */ + static class TestSignatureProxy extends SignatureProxy { + private String lastHashAlgorithm; + private byte[] lastMessage; + private final byte[] signatureToReturn; + + TestSignatureProxy(SkPublicKey publicKey, byte[] signatureToReturn) { + super(publicKey); + this.signatureToReturn = signatureToReturn; + } + + @Override + public byte[] sign(byte[] message, String hashAlgorithm) throws IOException { + this.lastMessage = message; + this.lastHashAlgorithm = hashAlgorithm; + return signatureToReturn; + } + + public String getLastHashAlgorithm() { + return lastHashAlgorithm; + } + + public byte[] getLastMessage() { + return lastMessage; + } + } + + @BeforeEach + public void setUp() { + authManager = new AuthenticationManager(tm); + } + + @Test + public void authenticateSkKey_WithoutSignatureProxy_ThrowsIOException() throws IOException { + TestSkPublicKey skKey = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + + // Setup mocks for initialization + setupMocksForAuthentication(); + + // Create a SignatureProxy that provides the SK public key + TestSignatureProxy proxyWithSkKey = new TestSignatureProxy(skKey, TEST_SIGNATURE); + + // Create a mock KeyPair with SK public key - this simulates trying to use an SK key + // without a proper SignatureProxy for signing + java.security.KeyPair skKeyPair = new java.security.KeyPair(skKey, null); + + setupMockForAuthFailWithSkKey(); + + IOException exception = assertThrows(IOException.class, () -> { + // This will fail because SK keys require a SignatureProxy for signing + authManager.authenticatePublicKey(TEST_USER, skKeyPair, null, null); + }); + + // The exception should indicate that SK key authentication requires a SignatureProxy + assertTrue(exception.getMessage().contains("SK key authentication requires a SignatureProxy") || + exception.getMessage().contains("Publickey authentication failed")); + } + + private void setupMockForAuthFailWithSkKey() throws IOException { + // Setup mock to simulate SSH authentication flow that gets to the SK key branch + final byte[] serviceAccept = new byte[] { 6 }; // SSH_MSG_SERVICE_ACCEPT + final byte[] userauthFailure = createUserauthFailure(new String[] { "publickey" }); + + // Queue messages for the authentication flow + new Thread(() -> { + try { + Thread.sleep(50); + authManager.handleMessage(serviceAccept, serviceAccept.length); + Thread.sleep(50); + authManager.handleMessage(userauthFailure, userauthFailure.length); + } catch (Exception e) { + // Ignore + } + }).start(); + } + + @Test + public void authenticateSkEd25519Key_UsesSha512() throws Exception { + TestSkPublicKey skKey = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + TestSignatureProxy signatureProxy = new TestSignatureProxy(skKey, TEST_SIGNATURE); + + setupMocksForAuthentication(); + setupMockForAuthSuccess(); + + authManager.authenticatePublicKey(TEST_USER, signatureProxy); + + assertEquals(SignatureProxy.SHA512, signatureProxy.getLastHashAlgorithm(), + "SK Ed25519 keys should use SHA512 for signing"); + } + + @Test + public void authenticateSkEcdsaKey_UsesSha256() throws Exception { + TestSkPublicKey skKey = new TestSkPublicKey(SK_ECDSA_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + TestSignatureProxy signatureProxy = new TestSignatureProxy(skKey, TEST_SIGNATURE); + + setupMocksForAuthentication(); + setupMockForAuthSuccess(); + + authManager.authenticatePublicKey(TEST_USER, signatureProxy); + + assertEquals(SignatureProxy.SHA256, signatureProxy.getLastHashAlgorithm(), + "SK ECDSA keys should use SHA256 for signing"); + } + + @Test + public void authenticateSkKey_SignatureProxyReceivesMessage() throws Exception { + TestSkPublicKey skKey = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + TestSignatureProxy signatureProxy = new TestSignatureProxy(skKey, TEST_SIGNATURE); + + setupMocksForAuthentication(); + setupMockForAuthSuccess(); + + authManager.authenticatePublicKey(TEST_USER, signatureProxy); + + // Verify that the SignatureProxy received a message to sign + byte[] signedMessage = signatureProxy.getLastMessage(); + assertTrue(signedMessage != null && signedMessage.length > 0, + "SignatureProxy should receive a message to sign"); + } + + @Test + public void authenticateSkKey_SendsAuthenticationRequest() throws Exception { + TestSkPublicKey skKey = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + TestSignatureProxy signatureProxy = new TestSignatureProxy(skKey, TEST_SIGNATURE); + + setupMocksForAuthentication(); + setupMockForAuthSuccess(); + + authManager.authenticatePublicKey(TEST_USER, signatureProxy); + + // Verify that messages were sent to the transport manager + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(byte[].class); + verify(tm, org.mockito.Mockito.atLeastOnce()).sendMessage(messageCaptor.capture()); + + // At least one message should have been sent + assertTrue(messageCaptor.getAllValues().size() > 0, + "Authentication should send at least one message"); + } + + private void setupMocksForAuthentication() throws IOException { + lenient().when(tm.getSessionIdentifier()).thenReturn(TEST_SESSION_ID); + lenient().when(tm.getExtensionInfo()).thenReturn(extensionInfo); + lenient().when(extensionInfo.getSignatureAlgorithmsAccepted()).thenReturn(new HashSet<>()); + } + + private void setupMockForAuthSuccess() throws IOException { + // Setup mock to simulate SSH authentication flow + // First message is service accept, then userauth failure (to get available methods), + // then auth success + final int[] messageCount = {0}; + + lenient().doAnswer(invocation -> { + messageCount[0]++; + return null; + }).when(tm).sendMessage(any(byte[].class)); + + lenient().doAnswer(invocation -> { + // Simulate message handler registration + return null; + }).when(tm).registerMessageHandler(any(), any(int.class), any(int.class)); + + lenient().doAnswer(invocation -> { + // Simulate message handler removal + return null; + }).when(tm).removeMessageHandler(any(), any(int.class), any(int.class)); + + // We need to inject messages into the authManager's packet queue + // This is done by calling handleMessage + // Simulate: service accept, then userauth failure with publickey available, then success + final byte[] serviceAccept = new byte[] { 6 }; // SSH_MSG_SERVICE_ACCEPT + final byte[] userauthFailure = createUserauthFailure(new String[] { "publickey" }); + final byte[] userauthSuccess = new byte[] { 52 }; // SSH_MSG_USERAUTH_SUCCESS + + // Queue messages for the authentication flow + new Thread(() -> { + try { + Thread.sleep(50); + authManager.handleMessage(serviceAccept, serviceAccept.length); + Thread.sleep(50); + authManager.handleMessage(userauthFailure, userauthFailure.length); + Thread.sleep(50); + authManager.handleMessage(userauthSuccess, userauthSuccess.length); + } catch (Exception e) { + // Ignore + } + }).start(); + } + + private byte[] createUserauthFailure(String[] methods) { + TypesWriter tw = new TypesWriter(); + tw.writeByte(51); // SSH_MSG_USERAUTH_FAILURE + + // Write name-list of methods + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < methods.length; i++) { + if (i > 0) sb.append(","); + sb.append(methods[i]); + } + tw.writeString(sb.toString()); + tw.writeBoolean(false); // partial success + + return tw.getBytes(); + } +} diff --git a/src/test/java/com/trilead/ssh2/signature/SkPublicKeyTest.java b/src/test/java/com/trilead/ssh2/signature/SkPublicKeyTest.java new file mode 100644 index 00000000..dc19ecbe --- /dev/null +++ b/src/test/java/com/trilead/ssh2/signature/SkPublicKeyTest.java @@ -0,0 +1,137 @@ +package com.trilead.ssh2.signature; + +import com.trilead.ssh2.packets.TypesWriter; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for the SkPublicKey interface. + */ +public class SkPublicKeyTest { + + private static final String SK_ED25519_KEY_TYPE = "sk-ssh-ed25519@openssh.com"; + private static final String SK_ECDSA_KEY_TYPE = "sk-ecdsa-sha2-nistp256@openssh.com"; + private static final String DEFAULT_APPLICATION = "ssh:"; + private static final byte[] TEST_KEY_DATA = new byte[] { + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20 + }; + + /** + * Test implementation of SkPublicKey for unit testing. + */ + static class TestSkPublicKey implements SkPublicKey { + private final String keyType; + private final String application; + private final byte[] keyData; + + TestSkPublicKey(String keyType, String application, byte[] keyData) { + this.keyType = keyType; + this.application = application; + this.keyData = keyData.clone(); + } + + @Override + public String getSshKeyType() { + return keyType; + } + + @Override + public String getApplication() { + return application; + } + + @Override + public byte[] getKeyData() { + return keyData.clone(); + } + + @Override + public String getAlgorithm() { + return keyType; + } + + @Override + public String getFormat() { + return "SSH"; + } + + @Override + public byte[] getEncoded() { + TypesWriter tw = new TypesWriter(); + tw.writeString(keyType); + tw.writeString(keyData, 0, keyData.length); + tw.writeString(application); + return tw.getBytes(); + } + } + + @Test + public void testSkEd25519KeyType() { + SkPublicKey key = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + assertEquals(SK_ED25519_KEY_TYPE, key.getSshKeyType()); + } + + @Test + public void testSkEcdsaKeyType() { + SkPublicKey key = new TestSkPublicKey(SK_ECDSA_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + assertEquals(SK_ECDSA_KEY_TYPE, key.getSshKeyType()); + } + + @Test + public void testApplicationId() { + SkPublicKey key = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + assertEquals(DEFAULT_APPLICATION, key.getApplication()); + } + + @Test + public void testCustomApplicationId() { + String customApp = "custom:app"; + SkPublicKey key = new TestSkPublicKey(SK_ED25519_KEY_TYPE, customApp, TEST_KEY_DATA); + assertEquals(customApp, key.getApplication()); + } + + @Test + public void testKeyData() { + SkPublicKey key = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + assertArrayEquals(TEST_KEY_DATA, key.getKeyData()); + } + + @Test + public void testEncodedFormat() { + SkPublicKey key = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + + byte[] encoded = key.getEncoded(); + + // Verify the encoded format contains key type, key data, and application + // The encoding should be: key_type_string + key_data_string + application_string + TypesWriter expected = new TypesWriter(); + expected.writeString(SK_ED25519_KEY_TYPE); + expected.writeString(TEST_KEY_DATA, 0, TEST_KEY_DATA.length); + expected.writeString(DEFAULT_APPLICATION); + + assertArrayEquals(expected.getBytes(), encoded); + } + + @Test + public void testAlgorithmReturnsKeyType() { + SkPublicKey key = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, TEST_KEY_DATA); + assertEquals(SK_ED25519_KEY_TYPE, key.getAlgorithm()); + } + + @Test + public void testKeyDataIsolation() { + byte[] originalData = TEST_KEY_DATA.clone(); + TestSkPublicKey key = new TestSkPublicKey(SK_ED25519_KEY_TYPE, DEFAULT_APPLICATION, originalData); + + // Modify the original data + originalData[0] = (byte) 0xFF; + + // The key's data should not be affected + assertArrayEquals(TEST_KEY_DATA, key.getKeyData()); + } +}