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
commit aa09183993bbde86be13c2c1243051ef367746d0 Author: lahiruj <[email protected]> AuthorDate: Mon Nov 24 23:59:46 2025 -0500 add requestCertificateMaterials method for direct certificate material access --- signer/README.md | 31 ++++ signer/signer-sdk-core/pom.xml | 7 - .../custos/signer/sdk/CertificateMaterials.java | 61 +++++++ .../org/apache/custos/signer/sdk/SshClient.java | 123 +++++++++---- .../apache/custos/signer/sdk/util/SshKeyUtils.java | 166 +++++++++++++++++ .../custos/signer/sdk/util/SshKeyUtilsTest.java | 202 +++++++++++++++++++++ .../signer/service/ca/SshCertificateSigner.java | 7 +- .../custos/signer/service/vault/OpenBaoClient.java | 5 +- 8 files changed, 552 insertions(+), 50 deletions(-) diff --git a/signer/README.md b/signer/README.md index 96c47ac28..c6ed0740c 100644 --- a/signer/README.md +++ b/signer/README.md @@ -210,6 +210,37 @@ download("/remote/output.txt","/local/output.txt"); } ``` +### Request Certificate Materials + +Get all SSH connection materials (private key, public key, certificate, and metadata) to use with your preferred SSH client or library: + +```java +// Request certificate materials +CertificateMaterials materials = sshClient.requestCertificateMaterials( + "hpcA", "user", 3600, oidcToken +); + +// Use materials with OpenSSH command-line or other SSH libraries +java.nio.file.Path keyFile = java.nio.file.Files.createTempFile("ssh-key", ""); +java.nio.file.Path certFile = java.nio.file.Files.createTempFile("ssh-cert", ""); + +java.nio.file.Files.write(keyFile, materials.getPrivateKeyPem().getBytes()); +java.nio.file.Files.write(certFile, materials.getOpensshCert().getBytes()); + +// Use with OpenSSH +// ssh -i keyFile -o CertificateFile=certFile user@host + +// Or use KeyPair object directly with other SSH libraries +KeyPair keyPair = materials.getKeyPair(); +byte[] certBytes = materials.getCertBytes(); +``` + +**OpenSSH Certificate Format**: The `opensshCert` field contains the certificate in OpenSSH string format: +``` [email protected] <base64-encoded-certificate> <comment> +``` +This format can be written directly to a file and used with OpenSSH command-line tools using the `-o CertificateFile=` option. + ### Configuration File Create `custos-sdk.yml`: diff --git a/signer/signer-sdk-core/pom.xml b/signer/signer-sdk-core/pom.xml index 1124e181d..4bfbad373 100644 --- a/signer/signer-sdk-core/pom.xml +++ b/signer/signer-sdk-core/pom.xml @@ -106,13 +106,6 @@ <target>17</target> </configuration> </plugin> - <plugin> - <groupId>org.apache.maven.plugins</groupId> - <artifactId>maven-surefire-plugin</artifactId> - <configuration> - <groups>unit</groups> - </configuration> - </plugin> </plugins> </build> diff --git a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/CertificateMaterials.java b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/CertificateMaterials.java new file mode 100644 index 000000000..596c7dcfc --- /dev/null +++ b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/CertificateMaterials.java @@ -0,0 +1,61 @@ +/* + * 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 java.security.KeyPair; +import java.time.Instant; + +/** + * Immutable response containing all SSH connection materials. + * <p> + * This class provides everything needed to establish an SSH connection: + * - Private and public keys (both as objects and string formats) + * - Signed certificate (both as bytes and OpenSSH string format) + * - Connection metadata (target host, port, username) + * - Certificate metadata (serial, validity, CA fingerprint) + * <p> + * + * @param keyPair In-memory keypair object + * @param privateKeyPem Private key in PEM format + * @param publicKeyOpenSsh Public key in OpenSSH format + * @param opensshCert Certificate in OpenSSH string format + * @param certBytes Certificate bytes (defensively copied in and out) + * @param serial Certificate serial number + * @param validAfter Certificate validity start + * @param validBefore Certificate validity end + * @param caFingerprint CA fingerprint + * @param targetHost Target SSH host + * @param targetPort Target SSH port + * @param targetUsername Target SSH username + */ +public record CertificateMaterials(KeyPair keyPair, String privateKeyPem, String publicKeyOpenSsh, String opensshCert, + byte[] certBytes, long serial, Instant validAfter, Instant validBefore, + String caFingerprint, String targetHost, int targetPort, String targetUsername) { + + public CertificateMaterials { + // Defensive copy of certBytes + certBytes = (certBytes != null) ? certBytes.clone() : null; + } + + @Override + public byte[] certBytes() { + return certBytes != null ? certBytes.clone() : null; + } +} + 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 2ad3fb4ca..6b1881218 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 @@ -23,13 +23,13 @@ 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.sdk.ssh.SshSession; +import org.apache.custos.signer.sdk.util.SshKeyUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; import java.security.KeyPair; -import java.security.KeyPairGenerator; import java.time.Duration; -import java.util.Base64; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -60,32 +60,31 @@ public class SshClient implements AutoCloseable { } /** - * Open an SSH session using client alias + * Request certificate materials (keys and certificate). + * This method returns all SSH connection materials (private key, public key, certificate, and metadata). * * @param clientAlias Alias configured in SDK configuration * @param principal SSH username (typically from OIDC token) * @param ttlSeconds Certificate TTL in seconds * @param userToken OIDC user access token - * @return SSH session for remote operations + * @return CertificateMaterials containing all SSH connection materials + * @throws SshClientException if certificate request fails */ - public SshSession openSession(String clientAlias, String principal, int ttlSeconds, String userToken) { + public CertificateMaterials requestCertificateMaterials(String clientAlias, String principal, int ttlSeconds, String userToken) { try { - logger.debug("Opening SSH session for client: {}, principal: {}, ttl: {}s", - clientAlias, principal, ttlSeconds); + logger.debug("Requesting certificate materials for client: {}, principal: {}, ttl: {}s", clientAlias, principal, ttlSeconds); // Resolve client alias to configuration SdkConfiguration.ClientConfig clientConfig = configuration.getClientConfig(clientAlias) .orElseThrow(() -> new IllegalArgumentException("Client alias not found: " + clientAlias)); - // Generate ephemeral SSH keypair - KeyPair keyPair = generateKeyPair(); - String contextId = generateContextId(clientAlias, principal); + // TODO: Support RSA and ECDSA key types in addition to Ed25519 + // Generate ephemeral Ed25519 keypair + KeyPair keyPair = SshKeyUtils.generateEd25519KeyPair(); - // Store keypair temporarily - keyStore.store(principal, contextId, keyPair, Duration.ofSeconds(ttlSeconds)); - - // Convert public key to SSH format - byte[] publicKeyBytes = toSshPublicKeyFormat(keyPair); + // Convert public key to OpenSSH format + String publicKeyOpenSsh = SshKeyUtils.keyPairToOpenSshPublicKey(keyPair); + byte[] publicKeyBytes = publicKeyOpenSsh.getBytes(StandardCharsets.UTF_8); // Request certificate from signer service SshSignerClient.CertificateResponse certResponse = signerClient.requestCertificate( @@ -97,26 +96,84 @@ public class SshClient implements AutoCloseable { userToken ); - logger.debug("Received certificate: serial={}, target={}:{}", - certResponse.getSerialNumber(), certResponse.getTargetHost(), certResponse.getTargetPort()); + logger.debug("Received certificate: serial={}, target={}:{}", certResponse.getSerialNumber(), certResponse.getTargetHost(), certResponse.getTargetPort()); - // Create SSH session - SshSession session = new SshSession( - certResponse.getTargetHost(), - certResponse.getTargetPort(), - certResponse.getTargetUsername(), + // Convert private key to PEM format + String privateKeyPem = SshKeyUtils.keyPairToPem(keyPair); + + // Convert certificate bytes to OpenSSH cert string format + // Use principal as comment + String opensshCert = SshKeyUtils.certBytesToOpenSshCertString( + certResponse.getCertificate(), principal); + + // Create defensive copy of certBytes + byte[] certBytesCopy = certResponse.getCertificate().clone(); + + // Build and return CertificateMaterials + CertificateMaterials materials = new CertificateMaterials( keyPair, - certResponse.getCertificate(), + privateKeyPem, + publicKeyOpenSsh, + opensshCert, + certBytesCopy, + certResponse.getSerialNumber(), certResponse.getValidAfter(), - certResponse.getValidBefore() + certResponse.getValidBefore(), + certResponse.getCaFingerprint(), + certResponse.getTargetHost(), + certResponse.getTargetPort(), + certResponse.getTargetUsername() + ); + + logger.info("Certificate materials prepared: client={}, principal={}, serial={}", clientAlias, principal, certResponse.getSerialNumber()); + + return materials; + + } catch (Exception e) { + logger.error("Failed to request certificate materials for client: {}, principal: {}", clientAlias, principal, e); + throw new SshClientException("Failed to request certificate materials", e); + } + } + + /** + * Open an SSH session using client alias. + * <p> + * This method uses {@link #requestCertificateMaterials(String, String, int, String)} + * internally and then manages the session lifecycle with keystore tracking. + * + * @param clientAlias Alias configured in SDK configuration + * @param principal SSH username (typically from OIDC token) + * @param ttlSeconds Certificate TTL in seconds + * @param userToken OIDC user access token + * @return SSH session for remote operations + */ + public SshSession openSession(String clientAlias, String principal, int ttlSeconds, String userToken) { + try { + logger.debug("Opening SSH session for client: {}, principal: {}, ttl: {}s", clientAlias, principal, ttlSeconds); + + // Request certificate materials (shared flow) + CertificateMaterials materials = requestCertificateMaterials(clientAlias, principal, ttlSeconds, userToken); + + // Store keypair in keystore for session tracking + String contextId = generateContextId(clientAlias, principal); + keyStore.store(principal, contextId, materials.keyPair(), Duration.ofSeconds(ttlSeconds)); + + // Create SSH session from materials + SshSession session = new SshSession( + materials.targetHost(), + materials.targetPort(), + materials.targetUsername(), + materials.keyPair(), + materials.certBytes(), + materials.validAfter(), + materials.validBefore() ); // Track active session for cleanup String sessionId = generateSessionId(clientAlias, principal); activeSessions.put(sessionId, session); - logger.info("SSH session opened: client={}, principal={}, target={}:{}", - clientAlias, principal, certResponse.getTargetHost(), certResponse.getTargetPort()); + logger.info("SSH session opened: client={}, principal={}, target={}:{}", clientAlias, principal, materials.targetHost(), materials.targetPort()); return session; @@ -196,11 +253,6 @@ public class SshClient implements AutoCloseable { logger.debug("SshClient closed successfully"); } - private KeyPair generateKeyPair() throws Exception { - KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519"); - return keyGen.generateKeyPair(); - } - private String generateContextId(String clientAlias, String principal) { return clientAlias + "-" + principal + "-" + System.currentTimeMillis(); } @@ -209,15 +261,6 @@ public class SshClient implements AutoCloseable { return clientAlias + "-" + principal + "-" + System.currentTimeMillis(); } - private byte[] toSshPublicKeyFormat(KeyPair keyPair) throws Exception { - // Convert KeyPair to SSH public key format - // TODO - use a proper SSH key library - String keyType = "ssh-ed25519"; - String publicKeyBase64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); - String sshKey = keyType + " " + publicKeyBase64 + " custos-generated"; - return sshKey.getBytes(); - } - /** * Builder for SshClient */ 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 new file mode 100644 index 000000000..132a6638f --- /dev/null +++ b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/util/SshKeyUtils.java @@ -0,0 +1,166 @@ +/* + * 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.util; + +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; + +import java.io.StringWriter; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +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(); + } + + /** + * Convert a private key to PEM format using Bouncy Castle. + * + * @param keyPair KeyPair containing the private key + * @return PEM-formatted private key string + * @throws Exception if conversion fails + */ + public static String keyPairToPem(KeyPair keyPair) throws Exception { + if (keyPair == null || keyPair.getPrivate() == null) { + throw new IllegalArgumentException("KeyPair or private key is null"); + } + + StringWriter writer = new StringWriter(); + try (JcaPEMWriter pemWriter = new JcaPEMWriter(writer)) { + pemWriter.writeObject(keyPair.getPrivate()); + } + return writer.toString(); + } + + /** + * 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); + + blob.writeInt(publicKeyBytes.length); + + blob.write(publicKeyBytes); + + blob.flush(); + } finally { + blob.close(); + } + + // 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"; + } + + /** + * Convert certificate bytes to OpenSSH certificate string format. + * <p> + * Format: "[email protected] <base64-encoded-certificate> <comment>" + * <p> + * The certificate bytes are base64-encoded and prefixed with the appropriate + * 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 { + 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); + + // Format: "[email protected] <base64> <comment>" + String certString = SSH_CERT_TYPE_ED25519 + " " + base64Cert; + if (comment != null && !comment.isEmpty()) { + certString += " " + comment; + } + + return certString; + } +} + 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 new file mode 100644 index 000000000..1d2cd65bd --- /dev/null +++ b/signer/signer-sdk-core/src/test/java/org/apache/custos/signer/sdk/util/SshKeyUtilsTest.java @@ -0,0 +1,202 @@ +/* + * 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.util; + +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +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.*; + +/** + * Unit tests for SshKeyUtils + */ +class SshKeyUtilsTest { + + @Test + void testGenerateEd25519KeyPair() throws Exception { + KeyPair keyPair = SshKeyUtils.generateEd25519KeyPair(); + + assertNotNull(keyPair); + assertNotNull(keyPair.getPrivate()); + assertNotNull(keyPair.getPublic()); + // Java reports Ed25519 as "EdDSA" + assertEquals("EdDSA", keyPair.getPublic().getAlgorithm()); + } + + @Test + void testKeyPairToPem() throws Exception { + KeyPair keyPair = SshKeyUtils.generateEd25519KeyPair(); + 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()); + } + } + + @Test + void testKeyPairToOpenSshPublicKey() throws Exception { + KeyPair keyPair = SshKeyUtils.generateEd25519KeyPair(); + 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 + // - 4 bytes: public key length (should be 32) + // - 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]); + } + + @Test + void testCertBytesToOpenSshCertString() throws Exception { + // 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]); + } + + @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); + } + + @Test + void testKeyPairToPemWithNullKeyPair() { + assertThrows(IllegalArgumentException.class, () -> { + SshKeyUtils.keyPairToPem(null); + }); + } + + @Test + void testKeyPairToOpenSshPublicKeyWithNullKeyPair() { + assertThrows(IllegalArgumentException.class, () -> { + SshKeyUtils.keyPairToOpenSshPublicKey(null); + }); + } + + @Test + void testCertBytesToOpenSshCertStringWithNullBytes() { + assertThrows(IllegalArgumentException.class, () -> { + SshKeyUtils.certBytesToOpenSshCertString(null, "comment"); + }); + } + + @Test + void testCertBytesToOpenSshCertStringWithEmptyBytes() { + assertThrows(IllegalArgumentException.class, () -> { + SshKeyUtils.certBytesToOpenSshCertString(new byte[0], "comment"); + }); + } +} + 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 988a39f28..80e1dda85 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 @@ -175,7 +175,8 @@ public class SshCertificateSigner { cert.setReserved(""); // CA public key - cert.setCaKeyType(SSH_KEY_TYPE_ED25519); // Assuming CA is Ed25519 + // TODO: Support multiple CA key types (RSA, ECDSA) + cert.setCaKeyType(SSH_KEY_TYPE_ED25519); // Currently Ed25519 supported cert.setCaPublicKey(extractPublicKeyBytes(caKeyPair.getPublic())); return cert; @@ -188,6 +189,7 @@ public class SshCertificateSigner { // Serialize certificate for signing byte[] certificateData = serializeCertificate(certificate); + // TODO: Add support for RSA and ECDSA signing algorithms // Sign using Ed25519 if (caPrivateKey.getAlgorithm().equals("Ed25519")) { Ed25519Signer signer = new Ed25519Signer(); @@ -207,6 +209,7 @@ 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.) // Write certificate type writeString(out, "[email protected]"); @@ -246,6 +249,7 @@ public class SshCertificateSigner { writeString(out, certificate.getCaKeyType()); writeBytes(out, certificate.getCaPublicKey()); + // TODO: Support signature types for RSA and ECDSA // Write signature writeString(out, "ssh-ed25519"); writeBytes(out, signature); @@ -259,6 +263,7 @@ 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) // Write certificate type writeString(out, "[email protected]"); diff --git a/signer/signer-service/src/main/java/org/apache/custos/signer/service/vault/OpenBaoClient.java b/signer/signer-service/src/main/java/org/apache/custos/signer/service/vault/OpenBaoClient.java index 67ccee82b..7548d0b57 100644 --- a/signer/signer-service/src/main/java/org/apache/custos/signer/service/vault/OpenBaoClient.java +++ b/signer/signer-service/src/main/java/org/apache/custos/signer/service/vault/OpenBaoClient.java @@ -171,8 +171,9 @@ public class OpenBaoClient { vaultOperations.write(currentPath, currentData); } + // TODO: Support RSA and ECDSA CA key types // Generate new next key - KeyPair newNextKey = generateKeyPair("ed25519"); // Default to ed25519 + KeyPair newNextKey = generateKeyPair("ed25519"); String nextPrivateKeyPem = toPemString(newNextKey.getPrivate()); String nextPublicKeyPem = toPemString(newNextKey.getPublic()); @@ -292,7 +293,7 @@ public class OpenBaoClient { } private KeyPair generateKeyPair(String algorithm) throws Exception { - // For now, only support ed25519. Can be extended to support RSA, ECDSA + // TODO: Add support for RSA and ECDSA key generation if ("ed25519".equalsIgnoreCase(algorithm)) { return generateEd25519KeyPair(); } else {
