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 <base64-encoded-ssh-wire-blob> <comment>" + * <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); }
