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 308fa080022103534007cc119cdf8be93c22e102
Author: lahiruj <[email protected]>
AuthorDate: Wed Nov 26 17:06:22 2025 -0500

    Fix SSH key/cert encoding, normalize client auth metadata, and align Vault 
KV v2 handling
---
 .../custos/signer/sdk/client/SshSignerClient.java  |   9 +-
 .../apache/custos/signer/sdk/util/SshKeyUtils.java | 154 +++++++++++++++++-
 .../signer/service/auth/ClientAuthInterceptor.java |  30 ++--
 .../signer/service/ca/SshCertificateSigner.java    | 168 +++++++++++++------
 .../signer/service/grpc/SshSignerGrpcService.java  |   4 +-
 .../signer/service/policy/PolicyEnforcer.java      |   7 +-
 .../custos/signer/service/vault/OpenBaoClient.java | 181 ++++++++++++++++-----
 7 files changed, 442 insertions(+), 111 deletions(-)

diff --git 
a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/client/SshSignerClient.java
 
b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/client/SshSignerClient.java
index 0073a4521..c1e5b456b 100644
--- 
a/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/client/SshSignerClient.java
+++ 
b/signer/signer-sdk-core/src/main/java/org/apache/custos/signer/sdk/client/SshSignerClient.java
@@ -104,8 +104,9 @@ public class SshSignerClient implements AutoCloseable {
                     .build();
 
             // Add authentication metadata
+            // Client ID must be in format: tenantId:clientId for the 
interceptor
             Metadata metadata = new Metadata();
-            metadata.put(CLIENT_ID_KEY, clientId);
+            metadata.put(CLIENT_ID_KEY, tenantId + ":" + clientId);
             metadata.put(CLIENT_SECRET_KEY, clientSecret);
 
             // Make gRPC call with metadata
@@ -162,8 +163,9 @@ public class SshSignerClient implements AutoCloseable {
             RevokeRequest request = requestBuilder.build();
 
             // Add authentication metadata
+            // Client ID must be in format: tenantId:clientId for the 
interceptor
             Metadata metadata = new Metadata();
-            metadata.put(CLIENT_ID_KEY, clientId);
+            metadata.put(CLIENT_ID_KEY, tenantId + ":" + clientId);
             metadata.put(CLIENT_SECRET_KEY, clientSecret);
 
             // Make gRPC call
@@ -198,8 +200,9 @@ public class SshSignerClient implements AutoCloseable {
                     .build();
 
             // Add authentication metadata
+            // Client ID must be in format: tenantId:clientId for the 
interceptor
             Metadata metadata = new Metadata();
-            metadata.put(CLIENT_ID_KEY, clientId);
+            metadata.put(CLIENT_ID_KEY, tenantId + ":" + clientId);
             metadata.put(CLIENT_SECRET_KEY, clientSecret);
 
             // Make gRPC call
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 132a6638f..5ebd43a7f 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,8 +18,12 @@
  */
 package org.apache.custos.signer.sdk.util;
 
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters;
+import org.bouncycastle.crypto.util.PrivateKeyFactory;
 import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
 
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
 import java.io.StringWriter;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
@@ -50,10 +54,19 @@ public final class SshKeyUtils {
     }
 
     /**
-     * Convert a private key to PEM format using Bouncy Castle.
+     * Convert a private key to OpenSSH format (for Ed25519).
+     * <p>
+     * OpenSSH format for Ed25519 private keys uses a specific binary format:
+     * - Magic string: "openssh-key-v1\0"
+     * - Cipher name: "none" (for unencrypted keys)
+     * - KDF name: "none"
+     * - Public key (SSH wire format)
+     * - Private key (32 bytes for Ed25519)
+     * <p>
+     * This format is required for SSH to accept Ed25519 keys.
      *
      * @param keyPair KeyPair containing the private key
-     * @return PEM-formatted private key string
+     * @return OpenSSH-formatted private key string (PEM encoded)
      * @throws Exception if conversion fails
      */
     public static String keyPairToPem(KeyPair keyPair) throws Exception {
@@ -61,6 +74,13 @@ public final class SshKeyUtils {
             throw new IllegalArgumentException("KeyPair or private key is 
null");
         }
 
+        // For Ed25519, generate OpenSSH format
+        String algorithm = keyPair.getPrivate().getAlgorithm();
+        if ("EdDSA".equals(algorithm) || "Ed25519".equals(algorithm)) {
+            return keyPairToOpenSshPrivateKey(keyPair);
+        }
+
+        // For other key types, use standard PKCS#8 format
         StringWriter writer = new StringWriter();
         try (JcaPEMWriter pemWriter = new JcaPEMWriter(writer)) {
             pemWriter.writeObject(keyPair.getPrivate());
@@ -68,6 +88,135 @@ public final class SshKeyUtils {
         return writer.toString();
     }
 
+    /**
+     * Convert Ed25519 key pair to OpenSSH private key format.
+     * <p>
+     * OpenSSH private key format structure:
+     * - Magic: "openssh-key-v1\0" (15 bytes + null terminator)
+     * - Cipher name length (4 bytes) + cipher name ("none" for unencrypted)
+     * - KDF name length (4 bytes) + KDF name ("none")
+     * - KDF options (4 bytes length + data)
+     * - Number of keys (4 bytes)
+     * - Public key
+     * - Private key
+     * <p>
+     * All encoded in base64 and wrapped in PEM headers.
+     *
+     * @param keyPair Ed25519 KeyPair
+     * @return OpenSSH private key in PEM format
+     * @throws Exception if conversion fails
+     */
+    private static String keyPairToOpenSshPrivateKey(KeyPair keyPair) throws 
Exception {
+        // Extract Ed25519 private key bytes (32 bytes)
+        Ed25519PrivateKeyParameters privateKeyParams = 
(Ed25519PrivateKeyParameters) 
PrivateKeyFactory.createKey(keyPair.getPrivate().getEncoded());
+        byte[] privateKeyBytes = privateKeyParams.getEncoded();
+
+        // Extract Ed25519 public key bytes (32 bytes)
+        byte[] encoded = keyPair.getPublic().getEncoded();
+        byte[] publicKeyBytes = new byte[32];
+        System.arraycopy(encoded, encoded.length - 32, publicKeyBytes, 0, 32);
+
+        // Build SSH wire format for public key
+        ByteArrayOutputStream publicKeyBlob = new ByteArrayOutputStream();
+        DataOutputStream publicKeyOut = new DataOutputStream(publicKeyBlob);
+        byte[] keyTypeBytes = 
SSH_KEY_TYPE_ED25519.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        publicKeyOut.writeInt(keyTypeBytes.length);
+        publicKeyOut.write(keyTypeBytes);
+        publicKeyOut.writeInt(publicKeyBytes.length);
+        publicKeyOut.write(publicKeyBytes);
+        publicKeyOut.flush();
+        byte[] publicKeyBlobBytes = publicKeyBlob.toByteArray();
+
+        // Build unencrypted private section as defined by PROTOCOL.key:
+        // uint32 checkint1, uint32 checkint2 (same value)
+        // string keytype
+        // string public key (same blob as above)
+        // string private key (for ed25519: 32-byte priv + 32-byte pub)
+        // string comment
+        // padding 1,2,3.. to block boundary
+        java.security.SecureRandom random = new java.security.SecureRandom();
+        int checkInt = random.nextInt();
+
+        ByteArrayOutputStream privateBuf = new ByteArrayOutputStream();
+        DataOutputStream privOut = new DataOutputStream(privateBuf);
+
+        privOut.writeInt(checkInt);
+        privOut.writeInt(checkInt);
+
+        writeString(privOut, 
SSH_KEY_TYPE_ED25519.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+
+        // public key (raw 32-byte Ed25519 public key)
+        writeString(privOut, publicKeyBytes);
+
+        // private key (64 bytes: private + public)
+        byte[] fullPrivate = new byte[privateKeyBytes.length + 
publicKeyBytes.length];
+        System.arraycopy(privateKeyBytes, 0, fullPrivate, 0, 
privateKeyBytes.length);
+        System.arraycopy(publicKeyBytes, 0, fullPrivate, 
privateKeyBytes.length, publicKeyBytes.length);
+        writeString(privOut, fullPrivate);
+
+        // comment
+        writeString(privOut, 
"custos-generated".getBytes(java.nio.charset.StandardCharsets.UTF_8));
+
+        // padding with 1..n bytes
+        int paddingNeeded = (8 - (privateBuf.size() % 8)) % 8;
+        for (int i = 1; i <= paddingNeeded; i++) {
+            privOut.write(i);
+        }
+        privOut.flush();
+        byte[] privateKeyBlobBytes = privateBuf.toByteArray();
+
+        // Build OpenSSH key format
+        ByteArrayOutputStream opensshKey = new ByteArrayOutputStream();
+        DataOutputStream out = new DataOutputStream(opensshKey);
+
+        // Magic string: "openssh-key-v1\0"
+        
out.write("openssh-key-v1\0".getBytes(java.nio.charset.StandardCharsets.UTF_8));
+
+        // Cipher name: "none" (unencrypted)
+        byte[] cipherName = 
"none".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        out.writeInt(cipherName.length);
+        out.write(cipherName);
+
+        // KDF name: "none"
+        byte[] kdfName = 
"none".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+        out.writeInt(kdfName.length);
+        out.write(kdfName);
+
+        // KDF options: empty (4 bytes for length = 0)
+        out.writeInt(0);
+
+        // Number of keys: 1
+        out.writeInt(1);
+
+        // Public key (with length prefix)
+        writeString(out, publicKeyBlobBytes);
+
+        // Private key (with length prefix)
+        writeString(out, privateKeyBlobBytes);
+
+        out.flush();
+
+        String base64Key = 
Base64.getEncoder().encodeToString(opensshKey.toByteArray());
+
+        // Format as PEM
+        StringBuilder pem = new StringBuilder();
+        pem.append("-----BEGIN OPENSSH PRIVATE KEY-----\n");
+        // Split base64 into 70-character lines
+        for (int i = 0; i < base64Key.length(); i += 70) {
+            int end = Math.min(i + 70, base64Key.length());
+            pem.append(base64Key, i, end);
+            pem.append("\n");
+        }
+        pem.append("-----END OPENSSH PRIVATE KEY-----\n");
+
+        return pem.toString();
+    }
+
+    private static void writeString(DataOutputStream out, byte[] data) throws 
Exception {
+        out.writeInt(data.length);
+        out.write(data);
+    }
+
     /**
      * Convert a public key to OpenSSH format.
      * <p>
@@ -163,4 +312,3 @@ public final class SshKeyUtils {
         return certString;
     }
 }
-
diff --git 
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/ClientAuthInterceptor.java
 
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/ClientAuthInterceptor.java
index c134069a3..41617188d 100644
--- 
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/ClientAuthInterceptor.java
+++ 
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/auth/ClientAuthInterceptor.java
@@ -25,6 +25,7 @@ import io.grpc.ServerCall;
 import io.grpc.ServerCallHandler;
 import io.grpc.ServerInterceptor;
 import io.grpc.Status;
+import net.devh.boot.grpc.server.interceptor.GrpcGlobalServerInterceptor;
 import org.apache.custos.signer.service.model.ClientSshConfigEntity;
 import org.apache.custos.signer.service.repo.ClientSshConfigRepository;
 import org.slf4j.Logger;
@@ -43,25 +44,22 @@ import java.util.Optional;
  * Extracts client credentials from gRPC metadata and validates them against 
stored secrets.
  */
 @Component
+@GrpcGlobalServerInterceptor
 public class ClientAuthInterceptor implements ServerInterceptor {
 
-    private static final Logger logger = 
LoggerFactory.getLogger(ClientAuthInterceptor.class);
+    // Context key for storing authenticated client config
+    public static final Context.Key<ClientSshConfigEntity> 
AUTHENTICATED_CLIENT_KEY = Context.key("authenticated-client");
 
+    private static final Logger logger = 
LoggerFactory.getLogger(ClientAuthInterceptor.class);
     // gRPC metadata keys for client authentication
-    private static final Metadata.Key<String> CLIENT_ID_KEY =
-            Metadata.Key.of("client-id", Metadata.ASCII_STRING_MARSHALLER);
-    private static final Metadata.Key<String> CLIENT_SECRET_KEY =
-            Metadata.Key.of("client-secret", Metadata.ASCII_STRING_MARSHALLER);
+    private static final Metadata.Key<String> CLIENT_ID_KEY = 
Metadata.Key.of("client-id", Metadata.ASCII_STRING_MARSHALLER);
+    private static final Metadata.Key<String> CLIENT_SECRET_KEY = 
Metadata.Key.of("client-secret", Metadata.ASCII_STRING_MARSHALLER);
 
-    // Context key for storing authenticated client config
-    public static final Context.Key<ClientSshConfigEntity> 
AUTHENTICATED_CLIENT_KEY =
-            Context.key("authenticated-client");
+    private final PasswordEncoder passwordEncoder = new 
BCryptPasswordEncoder();
 
     @Autowired
     private ClientSshConfigRepository clientConfigRepository;
 
-    private final PasswordEncoder passwordEncoder = new 
BCryptPasswordEncoder();
-
     @Override
     public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
             ServerCall<ReqT, RespT> call, Metadata headers, 
ServerCallHandler<ReqT, RespT> next) {
@@ -74,7 +72,7 @@ public class ClientAuthInterceptor implements 
ServerInterceptor {
             if (clientId == null || clientSecret == null) {
                 logger.warn("Missing client credentials in gRPC metadata");
                 call.close(Status.UNAUTHENTICATED.withDescription("Missing 
client credentials"), headers);
-                return new ServerCall.Listener<ReqT>() {
+                return new ServerCall.Listener<>() {
                 };
             }
 
@@ -83,7 +81,7 @@ public class ClientAuthInterceptor implements 
ServerInterceptor {
             if (clientIdParts.length != 2) {
                 logger.warn("Invalid client ID format: {}", clientId);
                 call.close(Status.UNAUTHENTICATED.withDescription("Invalid 
client ID format"), headers);
-                return new ServerCall.Listener<ReqT>() {
+                return new ServerCall.Listener<>() {
                 };
             }
 
@@ -91,8 +89,7 @@ public class ClientAuthInterceptor implements 
ServerInterceptor {
             String actualClientId = clientIdParts[1];
 
             // Lookup client configuration
-            Optional<ClientSshConfigEntity> clientConfigOpt =
-                    clientConfigRepository.findByTenantIdAndClientId(tenantId, 
actualClientId);
+            Optional<ClientSshConfigEntity> clientConfigOpt = 
clientConfigRepository.findByTenantIdAndClientId(tenantId, actualClientId);
 
             if (clientConfigOpt.isEmpty()) {
                 logger.warn("Client not found: tenant={}, client={}", 
tenantId, actualClientId);
@@ -122,8 +119,7 @@ public class ClientAuthInterceptor implements 
ServerInterceptor {
             logger.debug("Client authenticated successfully: tenant={}, 
client={}", tenantId, actualClientId);
 
             // Create authenticated context
-            Context authenticatedContext = Context.current()
-                    .withValue(AUTHENTICATED_CLIENT_KEY, clientConfig);
+            Context authenticatedContext = 
Context.current().withValue(AUTHENTICATED_CLIENT_KEY, clientConfig);
 
             // Continue with authenticated context
             return Contexts.interceptCall(authenticatedContext, call, headers, 
next);
@@ -131,7 +127,7 @@ public class ClientAuthInterceptor implements 
ServerInterceptor {
         } catch (Exception e) {
             logger.error("Authentication error", e);
             call.close(Status.UNAUTHENTICATED.withDescription("Authentication 
error"), headers);
-            return new ServerCall.Listener<ReqT>() {
+            return new ServerCall.Listener<>() {
             };
         }
     }
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 80e1dda85..3abcc158a 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
@@ -20,6 +20,7 @@ package org.apache.custos.signer.service.ca;
 
 import org.apache.custos.signer.service.vault.OpenBaoClient;
 import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters;
+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
 import org.bouncycastle.crypto.signers.Ed25519Signer;
 import org.bouncycastle.crypto.util.PrivateKeyFactory;
 import org.slf4j.Logger;
@@ -75,9 +76,12 @@ public class SshCertificateSigner {
             logger.debug("Parsed SSH public key: type={}, fingerprint={}",
                     publicKey.getKeyType(), publicKey.getFingerprint());
 
-            // Get CA private key
+            // Get CA private key, auto-generate if it doesn't exist
             KeyPair caKeyPair = openBaoClient.getCurrentCAKey(tenantId, 
clientId)
-                    .orElseThrow(() -> new RuntimeException("No CA key found 
for tenant: " + tenantId + ", client: " + clientId));
+                    .orElseGet(() -> {
+                        logger.info("No CA key found for tenant: {}, client: 
{}, auto-generating new CA key", tenantId, clientId);
+                        return openBaoClient.createCAKeyPair(tenantId, 
clientId, "ed25519");
+                    });
 
             // Get next serial number
             long serialNumber = openBaoClient.incrementSerialCounter(tenantId, 
clientId);
@@ -130,14 +134,35 @@ public class SshCertificateSigner {
         }
 
         String keyType = parts[0];
-        byte[] keyData = Base64.getDecoder().decode(parts[1]);
+        byte[] decoded = Base64.getDecoder().decode(parts[1]);
+
+        byte[] rawKeyBytes;
+        if (SSH_KEY_TYPE_ED25519.equals(keyType)) {
+            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 (!SSH_KEY_TYPE_ED25519.equals(embeddedType)) {
+                throw new IllegalArgumentException("Mismatched key type inside 
public key blob: " + embeddedType);
+            }
+            int pkLen = buf.getInt();
+            if (pkLen != 32) {
+                throw new IllegalArgumentException("Unexpected Ed25519 public 
key length: " + pkLen);
+            }
+            rawKeyBytes = new byte[pkLen];
+            buf.get(rawKeyBytes);
+        } else {
+            // For future RSA/ECDSA support, fall back to full decoded blob
+            rawKeyBytes = decoded;
+        }
 
         // Calculate fingerprint (SHA256 hash)
         MessageDigest digest = MessageDigest.getInstance("SHA-256");
-        byte[] hash = digest.digest(keyData);
+        byte[] hash = digest.digest(rawKeyBytes);
         String fingerprint = Base64.getEncoder().encodeToString(hash);
 
-        return new SshPublicKey(keyType, keyData, fingerprint);
+        return new SshPublicKey(keyType, rawKeyBytes, fingerprint);
     }
 
     /**
@@ -153,7 +178,7 @@ public class SshCertificateSigner {
         cert.setCertType(SSH_CERT_TYPE_USER);
         cert.setNonce(generateNonce());
         cert.setKeyType(publicKey.getKeyType());
-        cert.setPublicKey(publicKey.getKeyData());
+        cert.setPublicKey(publicKey.getKeyData()); // For Ed25519, raw 32-byte 
public key
         cert.setSerial(serialNumber);
         cert.setKeyId(keyId);
 
@@ -177,7 +202,11 @@ public class SshCertificateSigner {
         // CA public key
         // TODO: Support multiple CA key types (RSA, ECDSA)
         cert.setCaKeyType(SSH_KEY_TYPE_ED25519); // Currently Ed25519 supported
-        cert.setCaPublicKey(extractPublicKeyBytes(caKeyPair.getPublic()));
+        try {
+            cert.setCaPublicKey(toSshPublicKeyBlob(caKeyPair.getPublic()));
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to encode CA public key", e);
+        }
 
         return cert;
     }
@@ -191,7 +220,9 @@ public class SshCertificateSigner {
 
         // TODO: Add support for RSA and ECDSA signing algorithms
         // Sign using Ed25519
-        if (caPrivateKey.getAlgorithm().equals("Ed25519")) {
+        // Note: Java reports Ed25519 keys as "EdDSA" algorithm
+        String algorithm = caPrivateKey.getAlgorithm();
+        if ("EdDSA".equals(algorithm) || "Ed25519".equals(algorithm)) {
             Ed25519Signer signer = new Ed25519Signer();
             Ed25519PrivateKeyParameters privateKeyParams = 
(Ed25519PrivateKeyParameters)
                     PrivateKeyFactory.createKey(caPrivateKey.getEncoded());
@@ -210,49 +241,51 @@ public class SshCertificateSigner {
         ByteArrayOutputStream out = new ByteArrayOutputStream();
 
         // TODO: Select certificate format based on client key type 
([email protected], [email protected], etc.)
-        // Write certificate type
+        // Certificate type
         writeString(out, "[email protected]");
 
-        // Write nonce
+        // Nonce
         writeBytes(out, certificate.getNonce());
 
-        // Write public key
-        writeString(out, certificate.getKeyType());
+        // Subject public key (as SSH wire public key)
         writeBytes(out, certificate.getPublicKey());
 
-        // Write serial
+        // Serial
         writeUint64(out, certificate.getSerial());
 
-        // Write certificate type
+        // Certificate type (user/host)
         writeUint32(out, certificate.getCertType());
 
-        // Write key ID
+        // Key ID
         writeString(out, certificate.getKeyId());
 
-        // Write principals (single principal for now)
-        writeStringList(out, 
Collections.singletonList(certificate.getKeyId().split("@")[0]));
+        // Principals (list encoded inside a single string)
+        writeBytes(out, 
encodePrincipals(Collections.singletonList(certificate.getKeyId().split("@")[0])));
 
-        // Write validity period
+        // Validity
         writeUint64(out, certificate.getValidAfter());
         writeUint64(out, certificate.getValidBefore());
 
-        // Write critical options
-        writeStringMap(out, certificate.getCriticalOptions());
+        // Critical options
+        writeBytes(out, encodeOptions(certificate.getCriticalOptions()));
 
-        // Write extensions
-        writeStringMap(out, certificate.getExtensions());
+        // Extensions
+        writeBytes(out, encodeOptions(certificate.getExtensions()));
 
-        // Write reserved
-        writeString(out, certificate.getReserved());
+        // Reserved (empty)
+        writeBytes(out, certificate.getReserved() == null
+                ? new byte[0]
+                : certificate.getReserved().getBytes(StandardCharsets.UTF_8));
 
-        // Write CA public key
-        writeString(out, certificate.getCaKeyType());
+        // CA public key (as SSH wire public key)
         writeBytes(out, certificate.getCaPublicKey());
 
         // TODO: Support signature types for RSA and ECDSA
-        // Write signature
-        writeString(out, "ssh-ed25519");
-        writeBytes(out, signature);
+        // Signature (wrapped as SSH signature blob)
+        ByteArrayOutputStream sigBuf = new ByteArrayOutputStream();
+        writeString(sigBuf, "ssh-ed25519");
+        writeBytes(sigBuf, signature);
+        writeBytes(out, sigBuf.toByteArray());
 
         return out.toByteArray();
     }
@@ -264,48 +297,89 @@ public class SshCertificateSigner {
         ByteArrayOutputStream out = new ByteArrayOutputStream();
 
         // TODO: Select certificate format based on client key type (currently 
hardcoded to Ed25519)
-        // Write certificate type
+        // Certificate type
         writeString(out, "[email protected]");
 
-        // Write nonce
+        // Nonce
         writeBytes(out, certificate.getNonce());
 
-        // Write public key
-        writeString(out, certificate.getKeyType());
+        // Subject public key (as SSH wire public key)
         writeBytes(out, certificate.getPublicKey());
 
-        // Write serial
+        // Serial
         writeUint64(out, certificate.getSerial());
 
-        // Write certificate type
+        // Certificate type (user/host)
         writeUint32(out, certificate.getCertType());
 
-        // Write key ID
+        // Key ID
         writeString(out, certificate.getKeyId());
 
-        // Write principals
-        writeStringList(out, 
Collections.singletonList(certificate.getKeyId().split("@")[0]));
+        // Principals
+        writeBytes(out, 
encodePrincipals(Collections.singletonList(certificate.getKeyId().split("@")[0])));
 
-        // Write validity period
+        // Validity period
         writeUint64(out, certificate.getValidAfter());
         writeUint64(out, certificate.getValidBefore());
 
-        // Write critical options
-        writeStringMap(out, certificate.getCriticalOptions());
+        // Critical options
+        writeBytes(out, encodeOptions(certificate.getCriticalOptions()));
 
-        // Write extensions
-        writeStringMap(out, certificate.getExtensions());
+        // Extensions
+        writeBytes(out, encodeOptions(certificate.getExtensions()));
 
-        // Write reserved
-        writeString(out, certificate.getReserved());
+        // Reserved
+        writeBytes(out, certificate.getReserved() == null
+                ? new byte[0]
+                : certificate.getReserved().getBytes(StandardCharsets.UTF_8));
 
-        // Write CA public key
-        writeString(out, certificate.getCaKeyType());
+        // CA public key
         writeBytes(out, certificate.getCaPublicKey());
 
         return out.toByteArray();
     }
 
+    private byte[] encodePrincipals(List<String> principals) throws Exception {
+        ByteArrayOutputStream principalsBuf = new ByteArrayOutputStream();
+        for (String principal : principals) {
+            writeString(principalsBuf, principal);
+        }
+        return principalsBuf.toByteArray();
+    }
+
+    private byte[] encodeOptions(Map<String, String> options) throws Exception 
{
+        ByteArrayOutputStream optionsBuf = new ByteArrayOutputStream();
+        if (options != null && !options.isEmpty()) {
+            options.entrySet().stream()
+                    .sorted(Map.Entry.comparingByKey())
+                    .forEach(entry -> {
+                        try {
+                            writeString(optionsBuf, entry.getKey());
+                            writeString(optionsBuf, entry.getValue());
+                        } catch (Exception e) {
+                            throw new RuntimeException("Failed to encode 
options", e);
+                        }
+                    });
+        }
+        return optionsBuf.toByteArray();
+    }
+
+    private byte[] toSshPublicKeyBlob(PublicKey publicKey) throws Exception {
+        String algorithm = publicKey.getAlgorithm();
+        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();
+        }
+
+        throw new IllegalArgumentException("Unsupported CA public key type: " 
+ algorithm);
+    }
+
     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/grpc/SshSignerGrpcService.java
 
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/grpc/SshSignerGrpcService.java
index 0ecb836c4..fffdb5e6f 100644
--- 
a/signer/signer-service/src/main/java/org/apache/custos/signer/service/grpc/SshSignerGrpcService.java
+++ 
b/signer/signer-service/src/main/java/org/apache/custos/signer/service/grpc/SshSignerGrpcService.java
@@ -19,6 +19,7 @@
 package org.apache.custos.signer.service.grpc;
 
 import io.grpc.stub.StreamObserver;
+import net.devh.boot.grpc.server.service.GrpcService;
 import org.apache.custos.signer.service.audit.AuditLogger;
 import org.apache.custos.signer.service.auth.ClientAuthInterceptor;
 import org.apache.custos.signer.service.auth.OidcTokenValidator;
@@ -38,7 +39,6 @@ import org.apache.custos.signer.v1.SshSignerServiceGrpc;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
 
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
@@ -52,7 +52,7 @@ import java.util.Map;
  * gRPC service implementation for SSH certificate signing operations.
  * Handles Sign, Revoke, and GetJWKS requests.
  */
-@Service
+@GrpcService
 public class SshSignerGrpcService extends 
SshSignerServiceGrpc.SshSignerServiceImplBase {
 
     private static final Logger logger = 
LoggerFactory.getLogger(SshSignerGrpcService.class);
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 507c904fc..4424cefa6 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,7 +179,12 @@ public class PolicyEnforcer {
             String[] parts = publicKeyString.split("\\s+");
 
             if (parts.length >= 1) {
-                return parts[0];
+                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;
             }
 
             throw new IllegalArgumentException("Invalid SSH public key 
format");
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 7548d0b57..faabe4223 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
@@ -24,17 +24,19 @@ import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
 import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.vault.core.VaultOperations;
 import org.springframework.vault.support.VaultResponse;
 
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
 import java.io.StringReader;
 import java.io.StringWriter;
 import java.security.KeyPair;
 import java.security.PrivateKey;
 import java.security.PublicKey;
 import java.time.Instant;
+import java.util.Base64;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
@@ -53,15 +55,28 @@ public class OpenBaoClient {
     private static final String NEXT_KEY_PATH = "next";
     private static final String METADATA_PATH = "metadata";
 
-    @Autowired
-    private VaultOperations vaultOperations;
+    private final VaultOperations vaultOperations;
+
+    public OpenBaoClient(VaultOperations vaultOperations) {
+        this.vaultOperations = vaultOperations;
+    }
+
+    /**
+     * Build path for KV v2 secrets engine
+     * KV v2 uses "{mount}/data/{path}" format
+     */
+    private String buildKv2Path(String tenantId, String clientId, String 
suffix) {
+        // For KV v2, full path format is: 
{mount}/data/{tenant}/{client}/{suffix}
+        return String.format("%s/data/%s/%s/%s", CA_PATH_PREFIX, tenantId, 
clientId, suffix);
+    }
 
     /**
      * Retrieve the current active CA private key
      */
     public Optional<KeyPair> getCurrentCAKey(String tenantId, String clientId) 
{
         try {
-            String path = buildPath(tenantId, clientId, CURRENT_KEY_PATH);
+            // KV v2 uses "data/{path}" format
+            String path = buildKv2Path(tenantId, clientId, CURRENT_KEY_PATH);
             VaultResponse response = vaultOperations.read(path);
 
             if (response == null || response.getData() == null) {
@@ -69,7 +84,13 @@ public class OpenBaoClient {
                 return Optional.empty();
             }
 
-            Map<String, Object> data = response.getData();
+            // KV v2 wraps data in a "data" key
+            Map<String, Object> responseData = response.getData();
+            @SuppressWarnings("unchecked")
+            Map<String, Object> data = (Map<String, Object>) 
responseData.get("data");
+            if (data == null) {
+                data = responseData;
+            }
             String privateKeyPem = (String) data.get("private_key");
             String publicKeyPem = (String) data.get("public_key");
 
@@ -93,7 +114,8 @@ public class OpenBaoClient {
      */
     public Optional<KeyPair> getNextCAKey(String tenantId, String clientId) {
         try {
-            String path = buildPath(tenantId, clientId, NEXT_KEY_PATH);
+            // KV v2 uses "data/{path}" format
+            String path = buildKv2Path(tenantId, clientId, NEXT_KEY_PATH);
             VaultResponse response = vaultOperations.read(path);
 
             if (response == null || response.getData() == null) {
@@ -101,7 +123,13 @@ public class OpenBaoClient {
                 return Optional.empty();
             }
 
-            Map<String, Object> data = response.getData();
+            // KV v2 wraps data in a "data" key
+            Map<String, Object> responseData = response.getData();
+            @SuppressWarnings("unchecked")
+            Map<String, Object> data = (Map<String, Object>) 
responseData.get("data");
+            if (data == null) {
+                data = responseData;
+            }
             String privateKeyPem = (String) data.get("private_key");
             String publicKeyPem = (String) data.get("public_key");
 
@@ -129,17 +157,20 @@ public class OpenBaoClient {
             String privateKeyPem = toPemString(keyPair.getPrivate());
             String publicKeyPem = toPemString(keyPair.getPublic());
 
+            // For KV v2, data must be wrapped in a "data" key
+            Map<String, Object> dataMap = new HashMap<>();
+            dataMap.put("private_key", privateKeyPem);
+            dataMap.put("public_key", publicKeyPem);
+            dataMap.put("algorithm", algorithm);
+            dataMap.put("created_at", Instant.now().getEpochSecond());
+
             Map<String, Object> data = new HashMap<>();
-            data.put("private_key", privateKeyPem);
-            data.put("public_key", publicKeyPem);
-            data.put("algorithm", algorithm);
-            data.put("created_at", Instant.now().getEpochSecond());
+            data.put("data", dataMap);
 
-            String path = buildPath(tenantId, clientId, CURRENT_KEY_PATH);
+            String path = buildKv2Path(tenantId, clientId, CURRENT_KEY_PATH);
             vaultOperations.write(path, data);
 
-            logger.info("Created new CA keypair for tenant: {}, client: {}, 
algorithm: {}",
-                    tenantId, clientId, algorithm);
+            logger.info("Created new CA keypair for tenant: {}, client: {}, 
algorithm: {}", tenantId, clientId, algorithm);
             return keyPair;
 
         } catch (Exception e) {
@@ -162,12 +193,16 @@ public class OpenBaoClient {
                 String privateKeyPem = toPemString(nextKey.get().getPrivate());
                 String publicKeyPem = toPemString(nextKey.get().getPublic());
 
+                Map<String, Object> currentDataMap = new HashMap<>();
+                currentDataMap.put("private_key", privateKeyPem);
+                currentDataMap.put("public_key", publicKeyPem);
+                currentDataMap.put("rotated_at", 
Instant.now().getEpochSecond());
+
                 Map<String, Object> currentData = new HashMap<>();
-                currentData.put("private_key", privateKeyPem);
-                currentData.put("public_key", publicKeyPem);
-                currentData.put("rotated_at", Instant.now().getEpochSecond());
+                currentData.put("data", currentDataMap);
 
-                String currentPath = buildPath(tenantId, clientId, 
CURRENT_KEY_PATH);
+                // KV v2 uses "{mount}/data/{path}" format for write operations
+                String currentPath = buildKv2Path(tenantId, clientId, 
CURRENT_KEY_PATH);
                 vaultOperations.write(currentPath, currentData);
             }
 
@@ -177,12 +212,15 @@ public class OpenBaoClient {
             String nextPrivateKeyPem = toPemString(newNextKey.getPrivate());
             String nextPublicKeyPem = toPemString(newNextKey.getPublic());
 
+            Map<String, Object> nextDataMap = new HashMap<>();
+            nextDataMap.put("private_key", nextPrivateKeyPem);
+            nextDataMap.put("public_key", nextPublicKeyPem);
+            nextDataMap.put("created_at", Instant.now().getEpochSecond());
+
             Map<String, Object> nextData = new HashMap<>();
-            nextData.put("private_key", nextPrivateKeyPem);
-            nextData.put("public_key", nextPublicKeyPem);
-            nextData.put("created_at", Instant.now().getEpochSecond());
+            nextData.put("data", nextDataMap);
 
-            String nextPath = buildPath(tenantId, clientId, NEXT_KEY_PATH);
+            String nextPath = buildKv2Path(tenantId, clientId, NEXT_KEY_PATH);
             vaultOperations.write(nextPath, nextData);
 
             // Update metadata with new rotation timestamp
@@ -203,7 +241,7 @@ public class OpenBaoClient {
      */
     public Optional<CAMetadata> getCAMetadata(String tenantId, String 
clientId) {
         try {
-            String path = buildPath(tenantId, clientId, METADATA_PATH);
+            String path = buildKv2Path(tenantId, clientId, METADATA_PATH);
             VaultResponse response = vaultOperations.read(path);
 
             if (response == null || response.getData() == null) {
@@ -211,7 +249,12 @@ public class OpenBaoClient {
                 return Optional.empty();
             }
 
-            Map<String, Object> data = response.getData();
+            Map<String, Object> responseData = response.getData();
+            @SuppressWarnings("unchecked")
+            Map<String, Object> data = (Map<String, Object>) 
responseData.get("data");
+            if (data == null) {
+                data = responseData;
+            }
             CAMetadata metadata = new CAMetadata();
             metadata.setSerialCounter(getLongValue(data, "serial_counter", 
1L));
             metadata.setLastRotationAt(getLongValue(data, "last_rotation_at", 
Instant.now().getEpochSecond()));
@@ -233,15 +276,18 @@ public class OpenBaoClient {
      */
     public void saveCAMetadata(String tenantId, String clientId, CAMetadata 
metadata) {
         try {
+            Map<String, Object> dataMap = new HashMap<>();
+            dataMap.put("serial_counter", metadata.getSerialCounter());
+            dataMap.put("last_rotation_at", metadata.getLastRotationAt());
+            dataMap.put("next_rotation_at", metadata.getNextRotationAt());
+            dataMap.put("rotation_period_hours", 
metadata.getRotationPeriodHours());
+            dataMap.put("overlap_hours", metadata.getOverlapHours());
+            dataMap.put("updated_at", Instant.now().getEpochSecond());
+
             Map<String, Object> data = new HashMap<>();
-            data.put("serial_counter", metadata.getSerialCounter());
-            data.put("last_rotation_at", metadata.getLastRotationAt());
-            data.put("next_rotation_at", metadata.getNextRotationAt());
-            data.put("rotation_period_hours", 
metadata.getRotationPeriodHours());
-            data.put("overlap_hours", metadata.getOverlapHours());
-            data.put("updated_at", Instant.now().getEpochSecond());
-
-            String path = buildPath(tenantId, clientId, METADATA_PATH);
+            data.put("data", dataMap);
+
+            String path = buildKv2Path(tenantId, clientId, METADATA_PATH);
             vaultOperations.write(path, data);
 
             logger.debug("Saved CA metadata for tenant: {}, client: {}", 
tenantId, clientId);
@@ -288,8 +334,61 @@ public class OpenBaoClient {
         }
     }
 
-    private String buildPath(String tenantId, String clientId, String suffix) {
-        return String.format("%s/%s/%s/%s", CA_PATH_PREFIX, tenantId, 
clientId, suffix);
+    /**
+     * Get CA public key in OpenSSH format (for TrustedUserCAKeys 
configuration).
+     * <p>
+     * Format: "ssh-ed25519 &lt;base64-encoded-ssh-wire-blob&gt; 
&lt;comment&gt;"
+     * <p>
+     * This is the format required by SSH's TrustedUserCAKeys directive.
+     *
+     * @param tenantId Tenant ID
+     * @param clientId Client ID
+     * @return OpenSSH-formatted public key string, or empty if not found
+     */
+    public Optional<String> getCAPublicKeyOpenSsh(String tenantId, String 
clientId) {
+        try {
+            Optional<KeyPair> keyPairOpt = getCurrentCAKey(tenantId, clientId);
+            if (keyPairOpt.isPresent()) {
+                KeyPair keyPair = keyPairOpt.get();
+                // Extract Ed25519 public key bytes (32 bytes)
+                byte[] encoded = keyPair.getPublic().getEncoded();
+                if (encoded.length < 44) {
+                    logger.warn("Invalid Ed25519 public key encoding for 
tenant: {}, client: {}", tenantId, clientId);
+                    return Optional.empty();
+                }
+
+                // 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]
+                ByteArrayOutputStream blobStream = new ByteArrayOutputStream();
+                DataOutputStream blob = new DataOutputStream(blobStream);
+
+                try {
+                    // Write key type string length (4 bytes, big-endian)
+                    byte[] keyTypeBytes = 
"ssh-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>"
+                String opensshKey = "ssh-ed25519 " + base64Key + " custos-ca-" 
+ tenantId + "-" + clientId;
+                return Optional.of(opensshKey);
+            }
+            return Optional.empty();
+        } catch (Exception e) {
+            logger.error("Error converting CA public key to OpenSSH format for 
tenant: {}, client: {}", tenantId, clientId, e);
+            return Optional.empty();
+        }
     }
 
     private KeyPair generateKeyPair(String algorithm) throws Exception {
@@ -314,11 +413,12 @@ public class OpenBaoClient {
         privateKeyParser.close();
 
         PrivateKey privateKey;
-        if (privateKeyObj instanceof PEMKeyPair) {
-            PEMKeyPair keyPair = (PEMKeyPair) privateKeyObj;
+        JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
+        if (privateKeyObj instanceof PEMKeyPair keyPair) {
             // Convert Bouncy Castle private key to Java PrivateKey
-            JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
             privateKey = converter.getPrivateKey(keyPair.getPrivateKeyInfo());
+        } else if (privateKeyObj instanceof 
org.bouncycastle.asn1.pkcs.PrivateKeyInfo) {
+            privateKey = 
converter.getPrivateKey((org.bouncycastle.asn1.pkcs.PrivateKeyInfo) 
privateKeyObj);
         } else {
             privateKey = (PrivateKey) privateKeyObj;
         }
@@ -328,7 +428,12 @@ public class OpenBaoClient {
         Object publicKeyObj = publicKeyParser.readObject();
         publicKeyParser.close();
 
-        PublicKey publicKey = (PublicKey) publicKeyObj;
+        PublicKey publicKey;
+        if (publicKeyObj instanceof 
org.bouncycastle.asn1.x509.SubjectPublicKeyInfo) {
+            publicKey = 
converter.getPublicKey((org.bouncycastle.asn1.x509.SubjectPublicKeyInfo) 
publicKeyObj);
+        } else {
+            publicKey = (PublicKey) publicKeyObj;
+        }
 
         return new KeyPair(publicKey, privateKey);
     }


Reply via email to