This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch custos-signer
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
The following commit(s) were added to refs/heads/custos-signer by this push:
new 4c34533f3 support key generation using the types RSA and ECDSA
4c34533f3 is described below
commit 4c34533f35e7f16ed1e11669b3fd0f94c65e0f13
Author: lahiruj <[email protected]>
AuthorDate: Tue Dec 2 23:32:14 2025 -0500
support key generation using the types RSA and ECDSA
---
.../org/apache/custos/signer/sdk/SshClient.java | 25 ++-
.../custos/signer/sdk/config/SdkConfiguration.java | 26 +++
.../apache/custos/signer/sdk/util/SshKeyUtils.java | 194 +++++++++++++--------
.../signer/sdk/SshClientIntegrationTest.java | 107 ++++++++++++
.../signer/sdk/config/SdkConfigurationTest.java | 50 ++++++
.../custos/signer/sdk/util/SshKeyUtilsTest.java | 94 +++++-----
.../signer/service/ca/SshCertificateSigner.java | 137 +++++++++++++--
.../custos/signer/service/policy/KeyType.java | 48 +++++
.../signer/service/policy/PolicyEnforcer.java | 7 +-
9 files changed, 536 insertions(+), 152 deletions(-)
diff --git
a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/SshClient.java
b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/SshClient.java
index 6b1881218..2e21956fe 100644
---
a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/SshClient.java
+++
b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/SshClient.java
@@ -71,18 +71,31 @@ public class SshClient implements AutoCloseable {
* @throws SshClientException if certificate request fails
*/
public CertificateMaterials requestCertificateMaterials(String
clientAlias, String principal, int ttlSeconds, String userToken) {
+ return requestCertificateMaterials(clientAlias, principal, ttlSeconds,
userToken, null);
+ }
+
+ /**
+ * Request certificate materials specifying key type.
+ *
+ * @param clientAlias Alias configured in SDK configuration
+ * @param principal SSH username
+ * @param ttlSeconds TTL in seconds
+ * @param userToken OIDC user access token
+ * @param keyType Key type ("ed25519", "rsa", "ecdsa")
+ * @return CertificateMaterials
+ */
+ public CertificateMaterials requestCertificateMaterials(String
clientAlias, String principal, int ttlSeconds, String userToken, String
keyType) {
try {
- logger.debug("Requesting certificate materials for client: {},
principal: {}, ttl: {}s", clientAlias, principal, ttlSeconds);
+ logger.debug("Requesting certificate materials for client: {},
principal: {}, ttl: {}s, keyType: {}", clientAlias, principal, ttlSeconds,
keyType);
// Resolve client alias to configuration
SdkConfiguration.ClientConfig clientConfig =
configuration.getClientConfig(clientAlias)
.orElseThrow(() -> new IllegalArgumentException("Client
alias not found: " + clientAlias));
- // TODO: Support RSA and ECDSA key types in addition to Ed25519
- // Generate ephemeral Ed25519 keypair
- KeyPair keyPair = SshKeyUtils.generateEd25519KeyPair();
+ String defaultKeyType = clientConfig.getKeyType() == null ?
"ed25519" : clientConfig.getKeyType();
+ String normalizedKeyType = keyType == null ?
defaultKeyType.toLowerCase() : keyType.toLowerCase();
+ KeyPair keyPair = SshKeyUtils.generateKeyPair(normalizedKeyType);
- // Convert public key to OpenSSH format
String publicKeyOpenSsh =
SshKeyUtils.keyPairToOpenSshPublicKey(keyPair);
byte[] publicKeyBytes =
publicKeyOpenSsh.getBytes(StandardCharsets.UTF_8);
@@ -104,7 +117,7 @@ public class SshClient implements AutoCloseable {
// Convert certificate bytes to OpenSSH cert string format
// Use principal as comment
String opensshCert = SshKeyUtils.certBytesToOpenSshCertString(
- certResponse.getCertificate(), principal);
+ certResponse.getCertificate(), normalizedKeyType,
principal);
// Create defensive copy of certBytes
byte[] certBytesCopy = certResponse.getCertificate().clone();
diff --git
a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/config/SdkConfiguration.java
b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/config/SdkConfiguration.java
index 6548f4dcd..29698526c 100644
---
a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/config/SdkConfiguration.java
+++
b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/config/SdkConfiguration.java
@@ -19,6 +19,7 @@
package org.apache.custos.signer.sdk.config;
import com.fasterxml.jackson.annotation.JsonProperty;
+import org.apache.custos.signer.service.policy.KeyType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -327,6 +328,9 @@ public class SdkConfiguration {
@JsonProperty("client-secret")
private String clientSecret;
+ @JsonProperty("key-type")
+ private String keyType = KeyType.ED25519.id();
+
public ClientConfig() {
}
@@ -336,6 +340,15 @@ public class SdkConfiguration {
this.clientSecret = clientSecret;
}
+ public ClientConfig(String alias, String clientId, String
clientSecret, String keyType) {
+ this.alias = alias;
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ if (keyType != null && !keyType.isEmpty()) {
+ this.keyType = keyType.toLowerCase();
+ }
+ }
+
public String getAlias() {
return alias;
}
@@ -359,6 +372,14 @@ public class SdkConfiguration {
public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}
+
+ public String getKeyType() {
+ return keyType;
+ }
+
+ public void setKeyType(String keyType) {
+ this.keyType = keyType;
+ }
}
/**
@@ -425,6 +446,11 @@ public class SdkConfiguration {
return this;
}
+ public Builder addClient(String alias, String clientId, String
clientSecret, String keyType) {
+ clients.put(alias, new ClientConfig(alias, clientId, clientSecret,
keyType));
+ return this;
+ }
+
public SdkConfiguration build() {
sdkConfig.setSigner(signerConfig);
sdkConfig.setKeyStore(keyStoreConfig);
diff --git
a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/util/SshKeyUtils.java
b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/util/SshKeyUtils.java
index 5ebd43a7f..00f4d09f8 100644
---
a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/util/SshKeyUtils.java
+++
b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/util/SshKeyUtils.java
@@ -18,6 +18,7 @@
*/
package org.apache.custos.signer.sdk.util;
+import org.apache.custos.signer.service.policy.KeyType;
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters;
import org.bouncycastle.crypto.util.PrivateKeyFactory;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
@@ -25,32 +26,44 @@ import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.StringWriter;
+import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
import java.util.Base64;
/**
* Utility class for SSH key generation and format conversion.
- * TODO: Add support for RSA and ECDSA key types.
*/
public final class SshKeyUtils {
- // TODO: Support multiple key types (RSA, ECDSA) in addition to Ed25519
private static final String SSH_KEY_TYPE_ED25519 = "ssh-ed25519";
private static final String SSH_CERT_TYPE_ED25519 =
"[email protected]";
private SshKeyUtils() {
}
- /**
- * Generate a new Ed25519 keypair.
- *
- * @return Generated Ed25519 KeyPair
- * @throws Exception if key generation fails
- */
- public static KeyPair generateEd25519KeyPair() throws Exception {
- KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
- return keyGen.generateKeyPair();
+ public static KeyPair generateKeyPair(String keyType) throws Exception {
+ KeyType normalized = KeyType.from(keyType);
+ switch (normalized) {
+ case ED25519:
+ return
KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
+ case RSA:
+ KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA");
+ rsaGen.initialize(2048);
+ return rsaGen.generateKeyPair();
+ case ECDSA:
+ KeyPairGenerator ecGen = KeyPairGenerator.getInstance("EC");
+ ecGen.initialize(new ECGenParameterSpec("secp256r1"));
+ return ecGen.generateKeyPair();
+ default:
+ throw new IllegalArgumentException("Unsupported key type: " +
keyType);
+ }
}
/**
@@ -217,66 +230,65 @@ public final class SshKeyUtils {
out.write(data);
}
- /**
- * Convert a public key to OpenSSH format.
- * <p>
- * Format: "ssh-ed25519 <base64-encoded-ssh-wire-blob>
<comment>"
- * <p>
- * The SSH wire format blob contains:
- * - 4 bytes: length of key type string ("ssh-ed25519" = 11 bytes)
- * - key type string bytes ("ssh-ed25519")
- * - 4 bytes: length of public key (32 bytes)
- * - 32 bytes: public key
- * <p>
- * This entire blob is then base64-encoded to produce the OpenSSH public
key format.
- * <p>
- * TODO: Support RSA and ECDSA key types (detect key type and format
accordingly).
- *
- * @param keyPair KeyPair containing the public key
- * @return OpenSSH-formatted public key string
- * @throws Exception if conversion fails
- */
public static String keyPairToOpenSshPublicKey(KeyPair keyPair) throws
Exception {
if (keyPair == null || keyPair.getPublic() == null) {
throw new IllegalArgumentException("KeyPair or public key is
null");
}
- // Extract Ed25519 public key bytes (32 bytes)
- // The encoded format for Ed25519 is: OID (12 bytes) + public key (32
bytes) = 44 bytes
- byte[] encoded = keyPair.getPublic().getEncoded();
- if (encoded.length < 44) {
- throw new IllegalArgumentException("Invalid Ed25519 public key
encoding");
- }
-
- // Extract the 32-byte public key (last 32 bytes)
- byte[] publicKeyBytes = new byte[32];
- System.arraycopy(encoded, encoded.length - 32, publicKeyBytes, 0, 32);
-
- // SSH wire format: [4-byte length][key-type-string][4-byte
length][32-byte public key]
- java.io.ByteArrayOutputStream blobStream = new
java.io.ByteArrayOutputStream();
- java.io.DataOutputStream blob = new
java.io.DataOutputStream(blobStream);
-
- try {
- // Write key type string length (4 bytes, big-endian)
- byte[] keyTypeBytes =
SSH_KEY_TYPE_ED25519.getBytes(java.nio.charset.StandardCharsets.UTF_8);
- blob.writeInt(keyTypeBytes.length);
-
- blob.write(keyTypeBytes);
+ PublicKey publicKey = keyPair.getPublic();
+ String algorithm = publicKey.getAlgorithm();
- blob.writeInt(publicKeyBytes.length);
-
- blob.write(publicKeyBytes);
-
- blob.flush();
- } finally {
- blob.close();
+ if ("EdDSA".equals(algorithm) || "Ed25519".equals(algorithm)) {
+ byte[] encoded = publicKey.getEncoded();
+ if (encoded.length < 44) {
+ throw new IllegalArgumentException("Invalid Ed25519 public key
encoding");
+ }
+ byte[] publicKeyBytes = new byte[32];
+ System.arraycopy(encoded, encoded.length - 32, publicKeyBytes, 0,
32);
+
+ ByteArrayOutputStream blobStream = new ByteArrayOutputStream();
+ try (DataOutputStream blob = new DataOutputStream(blobStream)) {
+ writeString(blob,
SSH_KEY_TYPE_ED25519.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ blob.writeInt(publicKeyBytes.length);
+ blob.write(publicKeyBytes);
+ blob.flush();
+ }
+
+ String base64Key =
Base64.getEncoder().encodeToString(blobStream.toByteArray());
+ return SSH_KEY_TYPE_ED25519 + " " + base64Key + "
custos-generated";
+
+ } else if ("RSA".equalsIgnoreCase(algorithm)) {
+ RSAPublicKey rsa = (RSAPublicKey) publicKey;
+ ByteArrayOutputStream blobStream = new ByteArrayOutputStream();
+ try (DataOutputStream blob = new DataOutputStream(blobStream)) {
+ writeString(blob,
"ssh-rsa".getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ writeMpInt(blob, rsa.getPublicExponent());
+ writeMpInt(blob, rsa.getModulus());
+ blob.flush();
+ }
+
+ String base64Key =
Base64.getEncoder().encodeToString(blobStream.toByteArray());
+ return "ssh-rsa " + base64Key + " custos-generated";
+
+ } else if ("EC".equalsIgnoreCase(algorithm)) {
+ ECPublicKey ecKey = (ECPublicKey) publicKey;
+ String curveName = "nistp256";
+ ECPoint w = ecKey.getW();
+ byte[] q = encodeEcPointUncompressed(w, ecKey.getParams());
+
+ ByteArrayOutputStream blobStream = new ByteArrayOutputStream();
+ try (DataOutputStream blob = new DataOutputStream(blobStream)) {
+ writeString(blob, "ecdsa-sha2-" + curveName);
+ writeString(blob, curveName);
+ writeString(blob, q);
+ blob.flush();
+ }
+
+ String base64Key =
Base64.getEncoder().encodeToString(blobStream.toByteArray());
+ return "ecdsa-sha2-" + curveName + " " + base64Key + "
custos-generated";
+ } else {
+ throw new IllegalArgumentException("Unsupported public key
algorithm: " + algorithm);
}
-
- // Encode entire blob to base64
- String base64Key =
Base64.getEncoder().encodeToString(blobStream.toByteArray());
-
- // Format: "ssh-ed25519 <base64-ssh-wire-blob> <comment>"
- return SSH_KEY_TYPE_ED25519 + " " + base64Key + " custos-generated";
}
/**
@@ -288,27 +300,69 @@ public final class SshKeyUtils {
* certificate type identifier. The comment typically contains the
principal
* or client alias for identification.
* <p>
- * TODO: Detect certificate type from bytes and use appropriate format
([email protected], [email protected], etc.).
*
* @param certBytes Certificate bytes (raw binary certificate data)
* @param comment Comment to append (typically principal or clientAlias)
* @return OpenSSH certificate string format
* @throws Exception if conversion fails
*/
- public static String certBytesToOpenSshCertString(byte[] certBytes, String
comment) throws Exception {
+ public static String certBytesToOpenSshCertString(byte[] certBytes, String
keyType, String comment) throws Exception {
if (certBytes == null || certBytes.length == 0) {
throw new IllegalArgumentException("Certificate bytes cannot be
null or empty");
}
- // Encode certificate bytes to base64
- String base64Cert = Base64.getEncoder().encodeToString(certBytes);
+ KeyType normalized = KeyType.from(keyType);
+ String certType = normalized == KeyType.RSA
+ ? "[email protected]"
+ : normalized == KeyType.ECDSA
+ ? "[email protected]"
+ : SSH_CERT_TYPE_ED25519;
- // Format: "[email protected] <base64> <comment>"
- String certString = SSH_CERT_TYPE_ED25519 + " " + base64Cert;
+ String base64Cert = Base64.getEncoder().encodeToString(certBytes);
+ String certString = certType + " " + base64Cert;
if (comment != null && !comment.isEmpty()) {
certString += " " + comment;
}
return certString;
}
+
+ public static String certBytesToOpenSshCertString(byte[] certBytes, String
comment) throws Exception {
+ return certBytesToOpenSshCertString(certBytes, "ed25519", comment);
+ }
+
+ private static void writeMpInt(DataOutputStream out, BigInteger value)
throws Exception {
+ byte[] bytes = value.toByteArray();
+ writeString(out, bytes);
+ }
+
+ private static byte[] encodeEcPointUncompressed(ECPoint point,
ECParameterSpec params) {
+ int fieldSize = (params.getCurve().getField().getFieldSize() + 7) / 8;
+ byte[] x = toFixedLength(point.getAffineX(), fieldSize);
+ byte[] y = toFixedLength(point.getAffineY(), fieldSize);
+ byte[] q = new byte[1 + x.length + y.length];
+ q[0] = 0x04;
+ System.arraycopy(x, 0, q, 1, x.length);
+ System.arraycopy(y, 0, q, 1 + x.length, y.length);
+ return q;
+ }
+
+ private static byte[] toFixedLength(BigInteger value, int length) {
+ byte[] bytes = value.toByteArray();
+ if (bytes.length == length) {
+ return bytes;
+ }
+ byte[] out = new byte[length];
+ if (bytes.length > length) {
+ System.arraycopy(bytes, bytes.length - length, out, 0, length);
+ } else {
+ System.arraycopy(bytes, 0, out, length - bytes.length,
bytes.length);
+ }
+ return out;
+ }
+
+ private static void writeString(DataOutputStream out, String str) throws
Exception {
+ byte[] data = str.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ writeString(out, data);
+ }
}
diff --git
a/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/SshClientIntegrationTest.java
b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/SshClientIntegrationTest.java
new file mode 100644
index 000000000..1bb6b70b2
--- /dev/null
+++
b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/SshClientIntegrationTest.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 specific language
+ * governing permissions and limitations under the License.
+ */
+package org.apache.custos.signer.sdk;
+
+import org.apache.custos.signer.sdk.config.SdkConfiguration;
+import org.apache.custos.signer.sdk.keystore.InMemoryKeyStore;
+import org.apache.custos.signer.sdk.keystore.KeyStoreProvider;
+import org.apache.custos.signer.service.policy.KeyType;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test for SSH client certificate request functionality.
+ */
+public class SshClientIntegrationTest {
+
+ @ParameterizedTest
+ @MethodSource("keyTypes")
+ void testRequestCertificateMaterials(String keyType) throws Exception {
+ SdkConfiguration config = new SdkConfiguration.Builder()
+ .tenantId("nexus")
+ .signerServiceAddress("localhost:9095")
+ .tlsEnabled(false)
+ .keyStoreBackend("in-memory")
+ .addClient("test-client", "test-client", "test-secret")
+ .build();
+
+ KeyStoreProvider keyStore = new InMemoryKeyStore();
+
+ SshClient client = new SshClient.Builder()
+ .configuration(config)
+ .keyStoreBackend(keyStore)
+ .build();
+
+ try {
+ System.out.println("Requesting certificate materials (this will
auto-generate CA key if first time) using keyType=" + keyType);
+
+ CertificateMaterials materials =
client.requestCertificateMaterials(
+ "test-client", // client alias
+ "exouser", // principal (SSH username)
+ 3600, // TTL: 1 hour
+ "test-token", // user token (for now, just a placeholder
TODO)
+ keyType
+ );
+
+ assertNotNull(materials, "Certificate materials should not be
null");
+ assertNotNull(materials.keyPair(), "KeyPair should not be null");
+ assertNotNull(materials.privateKeyPem(), "Private key PEM should
not be null");
+ assertNotNull(materials.publicKeyOpenSsh(), "Public key OpenSSH
should not be null");
+ assertNotNull(materials.opensshCert(), "OpenSSH cert should not be
null");
+ assertNotNull(materials.certBytes(), "Certificate bytes should not
be null");
+ assertTrue(Objects.requireNonNull(materials.certBytes()).length >
0, "Certificate bytes should not be empty");
+
+ System.out.println("✓ Certificate materials received
successfully!");
+ System.out.println(" Serial Number: " + materials.serial());
+ System.out.println(" CA Fingerprint: " +
materials.caFingerprint());
+ System.out.println(" Target: " + materials.targetHost() + ":" +
materials.targetPort());
+ System.out.println(" Valid After: " + materials.validAfter());
+ System.out.println(" Valid Before: " + materials.validBefore());
+
+ String keyFile = "/tmp/test-key-" + keyType;
+ String certFile = "/tmp/test-cert-" + keyType;
+ Files.write(Paths.get(keyFile),
materials.privateKeyPem().getBytes());
+ String certContent = materials.opensshCert().trim();
+ Files.write(Paths.get(certFile), certContent.getBytes());
+
+ System.out.println("✓ Saved private key to: " + keyFile);
+ System.out.println("✓ Saved certificate to: " + certFile);
+ System.out.println("\nNext steps:");
+ System.out.println("1. Extract CA public key from Vault:");
+ System.out.println(" vault kv get -field=public_key
ssh-ca/nexus/test-client/current > /tmp/ca.pub");
+ System.out.println("2. Configure HPC node to trust the CA (see
Phase 4 in plan)");
+ System.out.println("3. Test SSH connection:");
+ System.out.println(" ssh -i " + keyFile + " -o CertificateFile="
+ certFile + " [email protected] -p 22");
+
+ } finally {
+ client.close();
+ }
+ }
+
+ private static Stream<String> keyTypes() {
+ return Stream.of(KeyType.ED25519.id(), KeyType.RSA.id(),
KeyType.ECDSA.id());
+ }
+}
diff --git
a/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/config/SdkConfigurationTest.java
b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/config/SdkConfigurationTest.java
new file mode 100644
index 000000000..e0b37ba38
--- /dev/null
+++
b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/config/SdkConfigurationTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 specific language
+ * governing permissions and limitations under the License.
+ */
+package org.apache.custos.signer.sdk.config;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class SdkConfigurationTest {
+
+ @Test
+ void defaultKeyTypeIsEd25519() {
+ SdkConfiguration config = new SdkConfiguration.Builder()
+ .tenantId("t1")
+ .signerServiceAddress("localhost:9095")
+ .addClient("a1", "c1", "s1")
+ .build();
+
+ SdkConfiguration.ClientConfig client =
config.getClientConfig("a1").orElseThrow();
+ assertEquals("ed25519", client.getKeyType());
+ }
+
+ @Test
+ void keyTypeCanBeOverriddenPerClient() {
+ SdkConfiguration config = new SdkConfiguration.Builder()
+ .tenantId("t1")
+ .signerServiceAddress("localhost:9095")
+ .addClient("a1", "c1", "s1", "rsa")
+ .addClient("a2", "c2", "s2", "ecdsa")
+ .build();
+
+ assertEquals("rsa",
config.getClientConfig("a1").orElseThrow().getKeyType());
+ assertEquals("ecdsa",
config.getClientConfig("a2").orElseThrow().getKeyType());
+ }
+}
diff --git
a/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/SshKeyUtilsTest.java
b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/SshKeyUtilsTest.java
index 1d2cd65bd..b43486b59 100644
---
a/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/SshKeyUtilsTest.java
+++
b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/SshKeyUtilsTest.java
@@ -18,15 +18,17 @@
*/
package org.apache.custos.signer.sdk.util;
-import org.bouncycastle.openssl.PEMParser;
-import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
+import org.apache.custos.signer.service.policy.KeyType;
import org.junit.jupiter.api.Test;
-import java.io.StringReader;
import java.security.KeyPair;
import java.util.Base64;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for SshKeyUtils
@@ -35,8 +37,8 @@ class SshKeyUtilsTest {
@Test
void testGenerateEd25519KeyPair() throws Exception {
- KeyPair keyPair = SshKeyUtils.generateEd25519KeyPair();
-
+ KeyPair keyPair = SshKeyUtils.generateKeyPair(KeyType.ED25519.id());
+
assertNotNull(keyPair);
assertNotNull(keyPair.getPrivate());
assertNotNull(keyPair.getPublic());
@@ -46,54 +48,41 @@ class SshKeyUtilsTest {
@Test
void testKeyPairToPem() throws Exception {
- KeyPair keyPair = SshKeyUtils.generateEd25519KeyPair();
+ KeyPair keyPair = SshKeyUtils.generateKeyPair(KeyType.ED25519.id());
String pem = SshKeyUtils.keyPairToPem(keyPair);
-
+
assertNotNull(pem);
- assertTrue(pem.contains("BEGIN") && pem.contains("PRIVATE KEY"));
- assertTrue(pem.contains("END") && pem.contains("PRIVATE KEY"));
-
- // Verify round-trip: parse PEM back to PrivateKey using Bouncy Castle
- try (PEMParser parser = new PEMParser(new StringReader(pem))) {
- Object obj = parser.readObject();
- assertNotNull(obj);
-
- JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
- java.security.PrivateKey parsedKey;
- if (obj instanceof org.bouncycastle.asn1.pkcs.PrivateKeyInfo) {
- parsedKey =
converter.getPrivateKey((org.bouncycastle.asn1.pkcs.PrivateKeyInfo) obj);
- } else if (obj instanceof org.bouncycastle.openssl.PEMKeyPair) {
- parsedKey =
converter.getPrivateKey(((org.bouncycastle.openssl.PEMKeyPair)
obj).getPrivateKeyInfo());
- } else {
- // Fallback: try to extract from key pair
- org.bouncycastle.openssl.PEMKeyPair keyPairObj =
(org.bouncycastle.openssl.PEMKeyPair) obj;
- parsedKey = converter.getKeyPair(keyPairObj).getPrivate();
- }
-
- assertNotNull(parsedKey);
- // Java reports Ed25519 as "EdDSA"
- assertEquals("EdDSA", parsedKey.getAlgorithm());
- }
+ assertTrue(pem.contains("BEGIN OPENSSH PRIVATE KEY"));
+ assertTrue(pem.contains("END OPENSSH PRIVATE KEY"));
+
+ // Verify OpenSSH header and basic structure
+ String base64 = pem.lines()
+ .filter(l -> !l.startsWith("-----"))
+ .reduce("", (a, b) -> a + b);
+ byte[] decoded = Base64.getDecoder().decode(base64);
+ assertNotNull(decoded);
+ assertTrue(new String(decoded, 0, "openssh-key-v1\0".length(),
java.nio.charset.StandardCharsets.UTF_8)
+ .startsWith("openssh-key-v1\0"));
}
@Test
void testKeyPairToOpenSshPublicKey() throws Exception {
- KeyPair keyPair = SshKeyUtils.generateEd25519KeyPair();
+ KeyPair keyPair = SshKeyUtils.generateKeyPair(KeyType.ED25519.id());
String opensshKey = SshKeyUtils.keyPairToOpenSshPublicKey(keyPair);
-
+
assertNotNull(opensshKey);
assertTrue(opensshKey.startsWith("ssh-ed25519 "));
-
+
// Verify format: "ssh-ed25519 <base64-ssh-wire-blob> <comment>"
String[] parts = opensshKey.split("\\s+");
assertEquals(3, parts.length);
assertEquals("ssh-ed25519", parts[0]);
-
+
// Decode the SSH wire format blob
byte[] wireBlob = Base64.getDecoder().decode(parts[1]);
assertNotNull(wireBlob);
assertTrue(wireBlob.length > 0);
-
+
// Verify SSH wire format structure:
// - 4 bytes: key type length (should be 11 for "ssh-ed25519")
// - key type string bytes
@@ -101,29 +90,29 @@ class SshKeyUtilsTest {
// - 32 bytes: public key
java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(wireBlob);
buffer.order(java.nio.ByteOrder.BIG_ENDIAN);
-
+
// Read key type length
int keyTypeLength = buffer.getInt();
assertEquals(11, keyTypeLength); // "ssh-ed25519" is 11 bytes
-
+
// Read key type string
byte[] keyTypeBytes = new byte[keyTypeLength];
buffer.get(keyTypeBytes);
String keyType = new String(keyTypeBytes,
java.nio.charset.StandardCharsets.UTF_8);
assertEquals("ssh-ed25519", keyType);
-
+
// Read public key length
int publicKeyLength = buffer.getInt();
assertEquals(32, publicKeyLength); // Ed25519 public key is 32 bytes
-
+
// Read public key
byte[] publicKeyBytes = new byte[publicKeyLength];
buffer.get(publicKeyBytes);
assertEquals(32, publicKeyBytes.length);
-
+
// Verify we consumed the entire blob
assertEquals(0, buffer.remaining());
-
+
// Verify comment
assertEquals("custos-generated", parts[2]);
}
@@ -133,21 +122,21 @@ class SshKeyUtilsTest {
// Create test certificate bytes (simplified - just test format)
byte[] certBytes = new byte[]{1, 2, 3, 4, 5};
String comment = "test-user";
-
+
String certString =
SshKeyUtils.certBytesToOpenSshCertString(certBytes, comment);
-
+
assertNotNull(certString);
assertTrue(certString.startsWith("[email protected] "));
-
+
// Verify format: "[email protected] <base64> <comment>"
String[] parts = certString.split("\\s+", 3);
assertEquals(3, parts.length);
assertEquals("[email protected]", parts[0]);
-
+
// Verify base64 decodes back to original bytes
byte[] decoded = Base64.getDecoder().decode(parts[1]);
assertArrayEquals(certBytes, decoded);
-
+
// Verify comment
assertEquals(comment, parts[2]);
}
@@ -155,17 +144,17 @@ class SshKeyUtilsTest {
@Test
void testCertBytesToOpenSshCertStringWithoutComment() throws Exception {
byte[] certBytes = new byte[]{1, 2, 3, 4, 5};
-
+
String certString =
SshKeyUtils.certBytesToOpenSshCertString(certBytes, null);
-
+
assertNotNull(certString);
assertTrue(certString.startsWith("[email protected] "));
-
+
// Should have prefix and base64, but no comment
String[] parts = certString.split("\\s+");
assertTrue(parts.length >= 2);
assertEquals("[email protected]", parts[0]);
-
+
// Verify base64 decodes back to original bytes
byte[] decoded = Base64.getDecoder().decode(parts[1]);
assertArrayEquals(certBytes, decoded);
@@ -199,4 +188,3 @@ class SshKeyUtilsTest {
});
}
}
-
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/ca/SshCertificateSigner.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/ca/SshCertificateSigner.java
index 3abcc158a..548f027b6 100644
---
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/ca/SshCertificateSigner.java
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/ca/SshCertificateSigner.java
@@ -35,6 +35,7 @@ import java.security.KeyPair;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.PublicKey;
+import java.security.Signature;
import java.time.Instant;
import java.util.Base64;
import java.util.Collections;
@@ -60,6 +61,7 @@ public class SshCertificateSigner {
private static final String SSH_KEY_TYPE_ED25519 = "ssh-ed25519";
private static final String SSH_KEY_TYPE_RSA = "ssh-rsa";
private static final String SSH_KEY_TYPE_ECDSA = "ecdsa-sha2-nistp256";
+ private static final String SSH_SIG_ALG_RSA_SHA256 = "rsa-sha2-256";
@Autowired
private OpenBaoClient openBaoClient;
@@ -153,8 +155,17 @@ public class SshCertificateSigner {
rawKeyBytes = new byte[pkLen];
buf.get(rawKeyBytes);
} else {
- // For future RSA/ECDSA support, fall back to full decoded blob
- rawKeyBytes = decoded;
+ ByteBuffer buf = ByteBuffer.wrap(decoded);
+ int typeLen = buf.getInt();
+ byte[] typeBytes = new byte[typeLen];
+ buf.get(typeBytes);
+ String embeddedType = new String(typeBytes,
StandardCharsets.UTF_8);
+ if (!embeddedType.equals(keyType)) {
+ throw new IllegalArgumentException("Mismatched key type inside
public key blob: " + embeddedType);
+ }
+ byte[] remaining = new byte[buf.remaining()];
+ buf.get(remaining);
+ rawKeyBytes = remaining;
}
// Calculate fingerprint (SHA256 hash)
@@ -200,8 +211,8 @@ public class SshCertificateSigner {
cert.setReserved("");
// CA public key
- // TODO: Support multiple CA key types (RSA, ECDSA)
- cert.setCaKeyType(SSH_KEY_TYPE_ED25519); // Currently Ed25519 supported
+ String caKeyType =
normalizeKeyType(caKeyPair.getPublic().getAlgorithm());
+ cert.setCaKeyType(caKeyType);
try {
cert.setCaPublicKey(toSshPublicKeyBlob(caKeyPair.getPublic()));
} catch (Exception e) {
@@ -218,7 +229,6 @@ public class SshCertificateSigner {
// Serialize certificate for signing
byte[] certificateData = serializeCertificate(certificate);
- // TODO: Add support for RSA and ECDSA signing algorithms
// Sign using Ed25519
// Note: Java reports Ed25519 keys as "EdDSA" algorithm
String algorithm = caPrivateKey.getAlgorithm();
@@ -229,6 +239,16 @@ public class SshCertificateSigner {
signer.init(true, privateKeyParams);
signer.update(certificateData, 0, certificateData.length);
return signer.generateSignature();
+ } else if ("RSA".equalsIgnoreCase(algorithm)) {
+ Signature signature = Signature.getInstance("SHA256withRSA");
+ signature.initSign(caPrivateKey);
+ signature.update(certificateData);
+ return signature.sign();
+ } else if ("EC".equalsIgnoreCase(algorithm)) {
+ Signature signature = Signature.getInstance("SHA256withECDSA");
+ signature.initSign(caPrivateKey);
+ signature.update(certificateData);
+ return signature.sign();
} else {
throw new IllegalArgumentException("Unsupported CA key algorithm:
" + caPrivateKey.getAlgorithm());
}
@@ -240,14 +260,12 @@ public class SshCertificateSigner {
private byte[] createFinalCertificate(SshCertificate certificate, byte[]
signature) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
- // TODO: Select certificate format based on client key type
([email protected], [email protected], etc.)
- // Certificate type
- writeString(out, "[email protected]");
+ writeString(out, getCertificateType(certificate.getKeyType()));
// Nonce
writeBytes(out, certificate.getNonce());
- // Subject public key (as SSH wire public key)
+ // Subject public key (SSH wire public key blob)
writeBytes(out, certificate.getPublicKey());
// Serial
@@ -280,10 +298,9 @@ public class SshCertificateSigner {
// CA public key (as SSH wire public key)
writeBytes(out, certificate.getCaPublicKey());
- // TODO: Support signature types for RSA and ECDSA
// Signature (wrapped as SSH signature blob)
ByteArrayOutputStream sigBuf = new ByteArrayOutputStream();
- writeString(sigBuf, "ssh-ed25519");
+ writeString(sigBuf, getSignatureAlgorithm(certificate.getCaKeyType()));
writeBytes(sigBuf, signature);
writeBytes(out, sigBuf.toByteArray());
@@ -296,14 +313,12 @@ public class SshCertificateSigner {
private byte[] serializeCertificate(SshCertificate certificate) throws
Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
- // TODO: Select certificate format based on client key type (currently
hardcoded to Ed25519)
- // Certificate type
- writeString(out, "[email protected]");
+ writeString(out, getCertificateType(certificate.getKeyType()));
// Nonce
writeBytes(out, certificate.getNonce());
- // Subject public key (as SSH wire public key)
+ // Subject public key (SSH wire public key blob)
writeBytes(out, certificate.getPublicKey());
// Serial
@@ -366,20 +381,108 @@ public class SshCertificateSigner {
private byte[] toSshPublicKeyBlob(PublicKey publicKey) throws Exception {
String algorithm = publicKey.getAlgorithm();
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
if ("EdDSA".equals(algorithm) || "Ed25519".equals(algorithm) ||
SSH_KEY_TYPE_ED25519.equals(algorithm)) {
Ed25519PublicKeyParameters publicKeyParams =
(Ed25519PublicKeyParameters)
org.bouncycastle.crypto.util.PublicKeyFactory.createKey(publicKey.getEncoded());
byte[] publicKeyBytes = publicKeyParams.getEncoded();
-
- ByteArrayOutputStream buf = new ByteArrayOutputStream();
writeString(buf, SSH_KEY_TYPE_ED25519);
writeBytes(buf, publicKeyBytes);
return buf.toByteArray();
+
+ } else if ("RSA".equalsIgnoreCase(algorithm)) {
+ java.security.interfaces.RSAPublicKey rsa =
(java.security.interfaces.RSAPublicKey) publicKey;
+ writeString(buf, SSH_KEY_TYPE_RSA);
+ writeMpInt(buf, rsa.getPublicExponent());
+ writeMpInt(buf, rsa.getModulus());
+ return buf.toByteArray();
+
+ } else if ("EC".equalsIgnoreCase(algorithm)) {
+ java.security.interfaces.ECPublicKey ec =
(java.security.interfaces.ECPublicKey) publicKey;
+ String curve = "nistp256";
+ byte[] q = encodeEcPoint(ec.getW(), ec.getParams());
+ writeString(buf, SSH_KEY_TYPE_ECDSA);
+ writeString(buf, curve);
+ writeBytes(buf, q);
+ return buf.toByteArray();
}
throw new IllegalArgumentException("Unsupported CA public key type: "
+ algorithm);
}
+ private String getCertificateType(String keyType) {
+ if (keyType == null) {
+ return SSH_KEY_TYPE_ED25519 + "[email protected]";
+ }
+ if (keyType.startsWith(SSH_KEY_TYPE_RSA)) {
+ return SSH_KEY_TYPE_RSA + "[email protected]";
+ }
+ if (keyType.startsWith(SSH_KEY_TYPE_ECDSA)) {
+ return SSH_KEY_TYPE_ECDSA + "[email protected]";
+ }
+ return SSH_KEY_TYPE_ED25519 + "[email protected]";
+ }
+
+ private String getSignatureAlgorithm(String caKeyType) {
+ if (caKeyType == null) {
+ return SSH_KEY_TYPE_ED25519;
+ }
+ if (caKeyType.startsWith(SSH_KEY_TYPE_RSA)) {
+ return SSH_SIG_ALG_RSA_SHA256;
+ }
+ if (caKeyType.startsWith(SSH_KEY_TYPE_ECDSA)) {
+ return SSH_KEY_TYPE_ECDSA;
+ }
+ return SSH_KEY_TYPE_ED25519;
+ }
+
+ private String normalizeKeyType(String algorithm) {
+ if (algorithm == null) {
+ return SSH_KEY_TYPE_ED25519;
+ }
+ if ("RSA".equalsIgnoreCase(algorithm)) {
+ return SSH_KEY_TYPE_RSA;
+ }
+ if ("EC".equalsIgnoreCase(algorithm)) {
+ return SSH_KEY_TYPE_ECDSA;
+ }
+ if ("Ed25519".equalsIgnoreCase(algorithm) ||
"EdDSA".equalsIgnoreCase(algorithm)) {
+ return SSH_KEY_TYPE_ED25519;
+ }
+ return algorithm;
+ }
+
+ private static byte[] encodeEcPoint(java.security.spec.ECPoint point,
java.security.spec.ECParameterSpec params) {
+ int fieldSize = (params.getCurve().getField().getFieldSize() + 7) / 8;
+ byte[] x = toFixedLength(point.getAffineX(), fieldSize);
+ byte[] y = toFixedLength(point.getAffineY(), fieldSize);
+ byte[] q = new byte[1 + x.length + y.length];
+ q[0] = 0x04;
+ System.arraycopy(x, 0, q, 1, x.length);
+ System.arraycopy(y, 0, q, 1 + x.length, y.length);
+ return q;
+ }
+
+ private static byte[] toFixedLength(java.math.BigInteger value, int
length) {
+ byte[] bytes = value.toByteArray();
+ if (bytes.length == length) {
+ return bytes;
+ }
+ byte[] out = new byte[length];
+ if (bytes.length > length) {
+ System.arraycopy(bytes, bytes.length - length, out, 0, length);
+ } else {
+ System.arraycopy(bytes, 0, out, length - bytes.length,
bytes.length);
+ }
+ return out;
+ }
+
+ private void writeMpInt(ByteArrayOutputStream out, java.math.BigInteger
value) throws Exception {
+ byte[] bytes = value.toByteArray();
+ writeUint32(out, bytes.length);
+ out.write(bytes);
+ }
+
private void writeString(ByteArrayOutputStream out, String str) throws
Exception {
byte[] bytes = str.getBytes(StandardCharsets.UTF_8);
writeUint32(out, bytes.length);
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/policy/KeyType.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/policy/KeyType.java
new file mode 100644
index 000000000..c30cb781c
--- /dev/null
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/policy/KeyType.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 specific language
+ * governing permissions and limitations under the License.
+ *
+ */
+package org.apache.custos.signer.service.policy;
+
+// TODO: move to shared/common module for reuse with SDK
+public enum KeyType {
+ ED25519("ed25519"),
+ RSA("rsa"),
+ ECDSA("ecdsa");
+
+ private final String id;
+
+ KeyType(String id) {
+ this.id = id;
+ }
+
+ public String id() {
+ return id;
+ }
+
+ public static KeyType from(String value) {
+ if (value == null) {
+ return ED25519;
+ }
+ String normalized = value.toLowerCase();
+ return switch (normalized) {
+ case "rsa", "ssh-rsa" -> RSA;
+ case "ecdsa", "ecdsa-sha2-nistp256" -> ECDSA;
+ default -> ED25519;
+ };
+ }
+}
diff --git
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/policy/PolicyEnforcer.java
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/policy/PolicyEnforcer.java
index 4424cefa6..e6d184916 100644
---
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/policy/PolicyEnforcer.java
+++
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/policy/PolicyEnforcer.java
@@ -179,12 +179,7 @@ public class PolicyEnforcer {
String[] parts = publicKeyString.split("\\s+");
if (parts.length >= 1) {
- String keyType = parts[0];
- // Normalize key type: remove "ssh-" prefix if present (e.g.,
"ssh-ed25519" -> "ed25519")
- if (keyType.startsWith("ssh-")) {
- keyType = keyType.substring(4);
- }
- return keyType;
+ return KeyType.from(parts[0]).id();
}
throw new IllegalArgumentException("Invalid SSH public key
format");