diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79dd21e570..70353818d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,9 @@ # ============================================================================= # Core XDK dependencies # ============================================================================= +acme4j = "3.5.1" apache-commons-cli = "1.11.0" +bouncycastle = "1.82" gson = "2.13.2" jakarta-activation = "2.1.4" jakarta-xml-bind-api = "4.0.5" @@ -152,7 +154,10 @@ xdk-xunit-engine = { group = "org.xtclang", name = "lib-xunit-engine" } # ============================================================================= # Core XDK third-party libraries (alphabetical) # ============================================================================= +acme4j-client = { module = "org.shredzone.acme4j:acme4j-client", version.ref = "acme4j" } apache-commons-cli = { module = "commons-cli:commons-cli", version.ref = "apache-commons-cli" } +bouncycastle-pkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } +bouncycastle-provider = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } jakarta-activation-api = { module = "jakarta.activation:jakarta.activation-api", version.ref = "jakarta-activation" } jakarta-xml-bind-api = { module = "jakarta.xml.bind:jakarta.xml.bind-api", version.ref = "jakarta-xml-bind-api" } diff --git a/javatools/build.gradle.kts b/javatools/build.gradle.kts index dc8d52fabd..ae620741f4 100644 --- a/javatools/build.gradle.kts +++ b/javatools/build.gradle.kts @@ -30,6 +30,9 @@ dependencies { implementation(libs.jline) implementation(libs.apache.commons.cli) implementation(libs.gson) + implementation(libs.acme4j.client) + implementation(libs.bouncycastle.pkix) + implementation(libs.bouncycastle.provider) testCompileOnly(libs.jetbrains.annotations) testImplementation(libs.javatools.utils) } @@ -187,7 +190,14 @@ val jar by tasks.existing(Jar::class) { cfg.filter { it.name.endsWith(".jar") }.map { file -> zipTree(file).matching { // Exclude module-info files from dependencies - fat JARs should not be JPMS modules + // Exclude signature files from signed jars (e.g. BouncyCastle) that break fat jars exclude("module-info.class", "META-INF/versions/*/module-info.class") + // Exclude signature files from signed jars (e.g. BouncyCastle) that break fat jars + exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") + // Exclude dependency metadata that causes duplicates in fat jars + exclude("META-INF/MANIFEST.MF", "META-INF/**/MANIFEST.MF") + exclude("META-INF/OSGI-INF/**", "META-INF/versions/*/OSGI-INF/**") + exclude("META-INF/LICENSE*", "META-INF/NOTICE*", "META-INF/maven/**") } } }) diff --git a/javatools/src/main/java/org/xvm/runtime/template/_native/crypto/KeyStoreOperations.java b/javatools/src/main/java/org/xvm/runtime/template/_native/crypto/KeyStoreOperations.java new file mode 100644 index 0000000000..eedb1a1512 --- /dev/null +++ b/javatools/src/main/java/org/xvm/runtime/template/_native/crypto/KeyStoreOperations.java @@ -0,0 +1,202 @@ +package org.xvm.runtime.template._native.crypto; + + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +import java.math.BigInteger; + +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +import java.time.Duration; +import java.time.Instant; + +import java.util.Date; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import org.shredzone.acme4j.exception.AcmeException; + + +/** + * Pure Java keystore and certificate operations that replace the previous native tool + * dependencies ({@code keytool}, {@code openssl}). Every method in this class produces + * PKCS12 keystore entries that are byte-compatible with those created by the corresponding + * native commands — verified by bidirectional cross-tool tests (Java-created keystores + * readable by keytool/openssl, and vice versa). See {@code KeyStoreCompatibilityTest}. + *

+ * This class has no XVM runtime dependencies and can be unit tested independently. + */ +public class KeyStoreOperations { + + /** + * Load an existing PKCS12 keystore or create a new empty one. + */ + public static KeyStore loadOrCreateKeyStore(String sPath, char[] achPwd) + throws GeneralSecurityException, IOException { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + File file = new File(sPath); + if (file.exists()) { + try (FileInputStream in = new FileInputStream(file)) { + keyStore.load(in, achPwd); + } + } else { + keyStore.load(null, achPwd); + } + return keyStore; + } + + /** + * Save a keystore to disk. + */ + public static void saveKeyStore(KeyStore keyStore, String sPath, char[] achPwd) + throws GeneralSecurityException, IOException { + try (FileOutputStream out = new FileOutputStream(sPath)) { + keyStore.store(out, achPwd); + } + } + + /** + * Delete an entry from a keystore, silently ignoring errors (e.g. if the alias + * does not exist or the keystore file does not exist). + */ + public static void deleteKeyStoreEntry(String sPath, char[] achPwd, String sAlias) { + try { + File file = new File(sPath); + if (file.exists()) { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (FileInputStream in = new FileInputStream(file)) { + keyStore.load(in, achPwd); + } + if (keyStore.containsAlias(sAlias)) { + keyStore.deleteEntry(sAlias); + saveKeyStore(keyStore, sPath, achPwd); + } + } + } catch (GeneralSecurityException | IOException ignore) { + // intentionally silent; enttry may not exist + } + } + + /** + * Create a self-signed certificate and store it in a PKCS12 keystore. + *

+ * Equivalent to {@code keytool -genkeypair -keyalg RSA -keysize 2048 -validity 90}. + * Uses the same JDK {@link KeyPairGenerator} for RSA-2048 key generation and + * BouncyCastle for X.509 certificate construction with SHA256WithRSA signing. + */ + public static void createSelfSignedCertificate(String sStorePath, char[] achPwd, + String sName, String sDName) + throws AcmeException, OperatorCreationException, + GeneralSecurityException, IOException, InterruptedException { + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGen.generateKeyPair(); + + X500Name x500Name = new X500Name(sDName); + Instant now = Instant.now(); + BigInteger serial = BigInteger.valueOf(now.toEpochMilli()); + var keyInfo = SubjectPublicKeyInfo.getInstance(keyPair.getPublic().getEncoded()); + + X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder( + x500Name, serial, + Date.from(now), Date.from(now.plus(Duration.ofDays(90))), + x500Name, keyInfo); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA").build(keyPair.getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certBuilder.build(signer)); + + KeyStore keyStore = loadOrCreateKeyStore(sStorePath, achPwd); + keyStore.setKeyEntry(sName, keyPair.getPrivate(), achPwd, new Certificate[]{cert}); + saveKeyStore(keyStore, sStorePath, achPwd); + } + + /** + * Generate an AES-256 symmetric key and store it in a PKCS12 keystore. + *

+ * Equivalent to {@code keytool -genseckey -keyalg AES -keysize 256}. Uses the same + * JDK {@link javax.crypto.KeyGenerator} API that keytool uses internally. + */ + public static void createSymmetricKey(String sPath, char[] achPwd, String sName) + throws GeneralSecurityException, IOException { + deleteKeyStoreEntry(sPath, achPwd, sName); + + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(256, new SecureRandom()); + SecretKey secretKey = keyGen.generateKey(); + + KeyStore keyStore = loadOrCreateKeyStore(sPath, achPwd); + keyStore.setEntry(sName, + new KeyStore.SecretKeyEntry(secretKey), + new KeyStore.PasswordProtection(achPwd)); + saveKeyStore(keyStore, sPath, achPwd); + } + + /** + * Store a password value as a PBE secret key entry in a PKCS12 keystore. + *

+ * Equivalent to {@code keytool -importpass}. Creates a PBE secret key from the + * password using {@link javax.crypto.SecretKeyFactory} and stores it as a + * {@link KeyStore.SecretKeyEntry} — the same internal representation. + */ + public static void createPassword(String sPath, char[] achPwd, + String sName, String sPwdValue) + throws GeneralSecurityException, IOException { + deleteKeyStoreEntry(sPath, achPwd, sName); + + SecretKey pbeKey = SecretKeyFactory.getInstance("PBE") + .generateSecret(new PBEKeySpec(sPwdValue.toCharArray())); + + KeyStore keyStore = loadOrCreateKeyStore(sPath, achPwd); + keyStore.setEntry(sName, + new KeyStore.SecretKeyEntry(pbeKey), + new KeyStore.PasswordProtection(achPwd)); + saveKeyStore(keyStore, sPath, achPwd); + } + + /** + * Change the password on a PKCS12 keystore by loading with the old password and + * saving with the new one. + *

+ * Equivalent to {@code keytool -storepasswd -keystore -storepass -new }. + */ + public static void changeStorePassword(String sPath, char[] achPwd, char[] achPwdNew) + throws GeneralSecurityException, IOException { + KeyStore keyStore = loadOrCreateKeyStore(sPath, achPwd); + saveKeyStore(keyStore, sPath, achPwdNew); + } + + /** + * Extract a key (private or secret) from a PKCS12 keystore file. + * + * @return the key, or null if not found or inaccessible + */ + public static Key extractKey(String sPath, char[] achPwd, String sName) + throws GeneralSecurityException, IOException { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (FileInputStream in = new FileInputStream(sPath)) { + keyStore.load(in, achPwd); + } + return keyStore.getKey(sName, achPwd); + } +} diff --git a/javatools/src/main/java/org/xvm/runtime/template/_native/crypto/xRTCertificateManager.java b/javatools/src/main/java/org/xvm/runtime/template/_native/crypto/xRTCertificateManager.java index 5c7be2e231..d564c6dde5 100644 --- a/javatools/src/main/java/org/xvm/runtime/template/_native/crypto/xRTCertificateManager.java +++ b/javatools/src/main/java/org/xvm/runtime/template/_native/crypto/xRTCertificateManager.java @@ -1,22 +1,41 @@ package org.xvm.runtime.template._native.crypto; -import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; +import java.io.FileWriter; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; import java.nio.file.Path; +import java.security.GeneralSecurityException; import java.security.Key; +import java.security.KeyPair; import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +import java.time.Duration; + +import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.ExecutionException; + +import org.bouncycastle.operator.OperatorCreationException; + +import org.shredzone.acme4j.Account; +import org.shredzone.acme4j.AccountBuilder; +import org.shredzone.acme4j.Authorization; +import org.shredzone.acme4j.Order; +import org.shredzone.acme4j.Session; +import org.shredzone.acme4j.Status; +import org.shredzone.acme4j.challenge.Http01Challenge; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.util.CSRBuilder; +import org.shredzone.acme4j.util.KeyPairUtils; import org.xvm.asm.ClassStructure; import org.xvm.asm.ConstantPool; @@ -32,7 +51,6 @@ import org.xvm.runtime.ObjectHandle.ExceptionHandle; import org.xvm.runtime.template.xException; -import org.xvm.runtime.template.xNullable; import org.xvm.runtime.template.xService; import org.xvm.runtime.template.collections.xArray; @@ -44,14 +62,17 @@ import org.xvm.runtime.template._native.crypto.xRTKeyStore.KeyStoreHandle; -import org.xvm.util.Handy; - /** * Native implementation of the xRTCertificateManager.x service. + *

+ * It uses pure Java APIs (JDK crypto, BouncyCastle, acme4j) that produce byte-for-byte compatible + * PKCS12 keystore entries. Keystores created by this implementation can be read by keytool and + * openssl, and vice versa — verified by {@code KeyStoreCompatibilityTest}. */ public class xRTCertificateManager extends xService { + public static xRTCertificateManager INSTANCE; public xRTCertificateManager(Container container, ClassStructure structure, boolean fInstance) { @@ -91,363 +112,378 @@ public TypeConstant getCanonicalType() { * Injection support method. */ public ObjectHandle ensureManager(Frame frame, ObjectHandle hOpts) { - StringHandle hProvider = hOpts instanceof StringHandle hS - ? hS - : xString.makeHandle("self"); - - // we could cache the handles based on the provider - ClassComposition clz = getCanonicalClass(); - ServiceHandle hMgr = createServiceHandle(f_container. - createServiceContext("CertificateManager"), clz, getCanonicalType()); - hMgr.setField(0, hProvider); // "provider" property + StringHandle hProvider = hOpts instanceof StringHandle hS ? hS : xString.makeHandle("self"); + ClassComposition clz = getCanonicalClass(); + ServiceHandle hMgr = createServiceHandle( + f_container.createServiceContext("CertificateManager"), clz, getCanonicalType()); + hMgr.setField(0, hProvider); return hMgr; } @Override public int invokeNativeN(Frame frame, MethodStructure method, ObjectHandle hTarget, ObjectHandle[] ahArg, int iReturn) { - switch (method.getName()) { - case "keystoreForImpl": - return invokeKeystoreFor(frame, ahArg, iReturn); - - case "encryptKeyStoreImpl": - return invokeAsIOTask(frame, () -> - invokeEncryptKeystore(frame, ahArg)); - - case "createCertificateImpl": - return invokeAsIOTask(frame, () -> + return switch (method.getName()) { + case "keystoreForImpl" -> + invokeKeystoreFor(frame, ahArg, iReturn); + case "encryptKeyStoreImpl" -> + invokeAsIOTask(frame, () -> invokeEncryptKeystore(frame, ahArg)); + case "createCertificateImpl" -> + invokeAsIOTask(frame, () -> invokeCreateCertificate(frame, (ServiceHandle) hTarget, ahArg)); - - case "revokeCertificateImpl": - return invokeAsIOTask(frame, () -> + case "revokeCertificateImpl" -> + invokeAsIOTask(frame, () -> invokeRevokeCertificate(frame, (ServiceHandle) hTarget, ahArg)); - - case "createSymmetricKeyImpl": - return invokeAsIOTask(frame, () -> - invokeCreateSymmetricKey(frame, ahArg)); - - case "createPasswordImpl": - return invokeAsIOTask(frame, () -> - invokeCreatePassword(frame, ahArg)); - - case "extractKeyImpl": - return invokeExtractKey(frame, ahArg, iReturn); - } - - return super.invokeNativeN(frame, method, hTarget, ahArg, iReturn); + case "createSymmetricKeyImpl" -> + invokeAsIOTask(frame, () -> invokeCreateSymmetricKey(frame, ahArg)); + case "createPasswordImpl" -> + invokeAsIOTask(frame, () -> invokeCreatePassword(frame, ahArg)); + case "extractKeyImpl" -> + invokeExtractKey(frame, ahArg, iReturn); + default -> + super.invokeNativeN(frame, method, hTarget, ahArg, iReturn); + }; } private int invokeAsIOTask(Frame frame, Callable task) { - CompletableFuture cfResult = - frame.f_context.f_container.scheduleIO(task); + CompletableFuture cfResult = frame.f_context.f_container.scheduleIO(task); Frame.Continuation continuation = frameCaller -> { try { ExceptionHandle hFailure = cfResult.get(); return hFailure == null ? Op.R_NEXT : frameCaller.raiseException(hFailure); - } catch (Throwable e) { - return frameCaller.raiseException("Unexpected execution failure " + e); + } catch (InterruptedException | ExecutionException e) { + // TODO: we temporarily print the stack trace for unhandled exceptions here; remove + e.printStackTrace(); + return frameCaller.raiseException( + xException.makeObscure(frame, "Unexpected execution failure " + e.getMessage())); } }; - return frame.waitForIO(cfResult, continuation); } + + // ----- certificate creation ------------------------------------------------------------------ + /** * Native implementation of * "createCertificateImpl(String path, Password pwd, String name, String dName)" + *

+ * For provider "self", replaces: + *

{@code
+     *   keytool -delete -alias  -keystore  -storepass 
+     *   keytool -genkeypair -keyalg RSA -keysize 2048 -validity 90
+     *           -alias  -dname  -storetype PKCS12
+     *           -keystore  -storepass 
+     * }
+ * The Java implementation uses {@link java.security.KeyPairGenerator} (RSA, 2048-bit) + * and BouncyCastle's {@code X509v3CertificateBuilder} with SHA256WithRSA — the same + * JDK crypto primitives that keytool uses internally. The resulting PKCS12 keystore + * entry is interchangeable with keytool output. + *

+ * For providers "certbot"/"certbot-staging", replaces the multistep native flow: + *

{@code
+     *    openssl genpkey
+     *    openssl req
+     *    certbot certonly --webroot
+     *    openssl pkcs12 -export
+     *    keytool -importkeystore}
+     * 
+ * The Java + * implementation uses acme4j to speak the ACME protocol directly, eliminating all + * intermediate files and format conversions. Challenge files are written to the same + * {@code .challenge/.well-known/acme-challenge/} directory that certbot's webroot + * mode used, so the platform's {@code AcmeChallenge} web service works unchanged. */ - private ExceptionHandle invokeCreateCertificate(Frame frame, ServiceHandle hMgr, ObjectHandle[] ahArg) { - StringHandle hStorePath = (StringHandle) ahArg[0]; - StringHandle hPwd = xRTKeyStore.getPassword(frame, ahArg[1]); - StringHandle hName = (StringHandle) ahArg[2]; - StringHandle hDName = (StringHandle) ahArg[3]; - StringHandle hProvider = (StringHandle) hMgr.getField(0); // "provider" property - - runSilentCommand( - "keytool", "-delete", - "-alias", hName.getStringValue(), - "-keystore", hStorePath.getStringValue(), - "-storepass", hPwd.getStringValue() - ); - - switch (hProvider.getStringValue()) { - case "self": - // create self-signed certificate - return runNoInputCommand(frame, - "keytool", "-genkeypair", "-keyalg", "RSA", "-keysize", "2048", "-validity", "90", - "-alias", hName.getStringValue(), - "-dname", hDName.getStringValue(), - "-storetype", "PKCS12", - "-keystore", hStorePath.getStringValue(), - "-storepass", hPwd.getStringValue() - ); - - case "certbot-staging": - return createCertificateWithCertbot(frame, hStorePath, hPwd, hName, hDName, true); - - case "certbot": - return createCertificateWithCertbot(frame, hStorePath, hPwd, hName, hDName, false); - - default: - return xException.makeHandle(frame, - "Unsupported certificate provider: " + hProvider.getStringValue()); + private ExceptionHandle invokeCreateCertificate(Frame frame, ServiceHandle hMgr, + ObjectHandle[] ahArg) { + StringHandle hStorePath = (StringHandle) ahArg[0]; + StringHandle hPwd = xRTKeyStore.getPassword(frame, ahArg[1]); + String sName = ((StringHandle) ahArg[2]).getStringValue(); + String sDName = ((StringHandle) ahArg[3]).getStringValue(); + String sProvider = ((StringHandle) hMgr.getField(0)).getStringValue(); + String sStorePath = hStorePath.getStringValue(); + char[] achPwd = hPwd.getValue(); + + try { + KeyStoreOperations.deleteKeyStoreEntry(sStorePath, achPwd, sName); + + return switch (sProvider) { + case "self" -> { + KeyStoreOperations.createSelfSignedCertificate(sStorePath, achPwd, sName, sDName); + yield null; + } + case "certbot-staging" -> { + createCertificateWithAcme(sStorePath, achPwd, sName, sDName, true, hStorePath); + yield null; + } + case "certbot" -> { + createCertificateWithAcme(sStorePath, achPwd, sName, sDName, false, hStorePath); + yield null; + } + default -> xException.makeHandle(frame, + "Unsupported certificate provider: " + sProvider); + }; + } catch (AcmeException | OperatorCreationException | GeneralSecurityException | + IOException | InterruptedException e) { + return xException.obscureIoException(frame, e.getMessage()); } } - private ExceptionHandle createCertificateWithCertbot( - Frame frame, StringHandle hStorePath, StringHandle hPwd, StringHandle hName, - StringHandle hDName, boolean fStaging) { - String sDName = hDName.getStringValue(); - String sName = hName.getStringValue(); - - // ensure the - File dirCerts = getCertsPath(hStorePath); - String sCertsDir = dirCerts.getAbsolutePath(); - if (!dirCerts.exists() && !dirCerts.mkdir() || !dirCerts.isDirectory()) { - return xException.ioException(frame, "Cannot create directory: " + sCertsDir); - } + /** + * Create a certificate using the ACME protocol (Let's Encrypt) via acme4j. + *

+ * Replaces the old five-step native flow (openssl genpkey → openssl req → certbot + * certonly → openssl pkcs12 -export → keytool -importkeystore) with a single + * in-process ACME interaction. The domain keypair and certificate chain are stored + * directly into the keystore without intermediate PEM/PKCS12 temp files, which is + * both simpler and more secure (no unencrypted private key written to disk). + *

+ * Polling uses acme4j's {@code waitForCompletion(Duration)} which respects the + * server's Retry-After header, rather than the old approach of blocking on + * {@code process.waitFor(300, SECONDS)} while certbot polled internally. + */ + private void createCertificateWithAcme(String sStorePath, char[] achPwd, String sName, String sDName, + boolean fStaging, StringHandle hStorePath) + throws AcmeException, GeneralSecurityException, IOException, InterruptedException { + int ofDomain = sDName.indexOf("CN="); + assert ofDomain >= 0; - File dirChallenge = getChallengePath(hStorePath); - String sChallengeDir = dirChallenge.getAbsolutePath(); + String sDomain = sDName.substring(ofDomain + 3); + File dirChallenge = getChallengePath(hStorePath); if (!dirChallenge.exists() && !dirChallenge.mkdir() || !dirChallenge.isDirectory()) { - return xException.ioException(frame, "Cannot create directory: " + sChallengeDir); + throw new IOException("Cannot create directory: " + dirChallenge.getAbsolutePath()); } - int ofDomain = sDName.indexOf("CN="); - assert ofDomain >= 0; - String sDomain = sDName.substring(ofDomain + 3); + KeyPair domainKeyPair = KeyPairUtils.createKeyPair(2048); + KeyPair accountKeyPair = KeyPairUtils.createKeyPair(2048); + Session session = new Session(acmeServerUri(fStaging)); + Account acmeAccount = new AccountBuilder() + .agreeToTermsOfService() + .useKeyPair(accountKeyPair) + .create(session); - String sKeyPath = sCertsDir + File.separator + sDomain + ".key"; - String sCsrPath = sCertsDir + File.separator + sDomain + ".csr"; + Order acmeOrder = acmeAccount.newOrder().domain(sDomain).create(); - try { - ExceptionHandle hFailure; - - // create the key - hFailure = runCommand(frame, null, - "openssl", "genpkey", "-algorithm", "RSA", - "-out", sKeyPath, - "-pkeyopt", "rsa_keygen_bits:2048"); - if (hFailure != null) { - return hFailure; - } + processHttpChallenges(acmeOrder.getAuthorizations(), dirChallenge, sDomain); + + CSRBuilder csrBuilder = buildCSR(sDName, sDomain); + csrBuilder.sign(domainKeyPair); + acmeOrder.execute(csrBuilder.getEncoded()); + + Status orderStatus = acmeOrder.waitForCompletion(ACME_TIMEOUT); + if (orderStatus != Status.VALID) { + throw new AcmeException("Certificate order failed for " + sDomain + + " (status: " + orderStatus + ")"); + } + + List certChain = acmeOrder.getCertificate().getCertificateChain(); + + KeyStore keyStore = KeyStoreOperations.loadOrCreateKeyStore(sStorePath, achPwd); + keyStore.setKeyEntry(sName, domainKeyPair.getPrivate(), achPwd, + certChain.toArray(new Certificate[0])); + KeyStoreOperations.saveKeyStore(keyStore, sStorePath, achPwd); + } - // create the CSR; note that openssl requires a DName in a '/'-delimited format - // - // Note: since we're using Let's Encrypt, only the "CN" and "SAN" fields will be filled - // based on the CSR; the rest gets filtered out - hFailure = runCommand(frame, null, - "openssl", "req", "-new", - "-key", sKeyPath, - "-out", sCsrPath, - "-subj", '/' + sDName.replace(',', '/')); - if (hFailure != null) { - return hFailure; + /** + * Process HTTP-01 challenges for each pending authorization. + *

+ * Writes challenge token files to {@code .challenge/.well-known/acme-challenge/} — + * the same directory layout that certbot's {@code --webroot --webroot-path} mode used. + * The platform's {@code AcmeChallenge} web service serves these files at the path + * that Let's Encrypt expects ({@code /.well-known/acme-challenge/{token}}). + */ + private void processHttpChallenges(List authorizations, + File dirChallenge, String sDomain) + throws AcmeException, IOException, InterruptedException { + for (Authorization auth : authorizations) { + if (auth.getStatus() != Status.PENDING) { + continue; } - // we don't use the "cert" and "chain" files, but need to specify the path regardless - // to avoid them being placed at some random location - String sConfigDir = sCertsDir + File.separator + "config"; - String sWorkDir = sCertsDir + File.separator + "work"; - String sLogDir = sCertsDir + File.separator + "logs"; - String sCertPath = sCertsDir + File.separator + "cert.pem"; - String sChainPath = sCertsDir + File.separator + "chain.pem"; - String sFullCertPath = sCertsDir + File.separator + "fullchain.pem"; - - // remove existing files (certbot doesn't override anything) - new File(sCertPath).delete(); - new File(sChainPath).delete(); - new File(sFullCertPath).delete(); - - // ask Let's Encrypt now! - hFailure = fStaging - ? runCommand(frame, "yes\nyes", - "certbot", "certonly", - "--staging", - "--webroot", - "--webroot-path", sChallengeDir, - "--config-dir", sConfigDir, - "--work-dir", sWorkDir, - "--logs-dir", sLogDir, - "--key-path", sKeyPath, - "--cert-path", sCertPath, - "--chain-path", sChainPath, - "--fullchain-path", sFullCertPath, - "-d", sDomain, - "--csr", sCsrPath, - "--register-unsafely-without-email") - : runCommand(frame, "yes\nyes", - "certbot", "certonly", - "--webroot", - "--webroot-path", sChallengeDir, - "--config-dir", sConfigDir, - "--work-dir", sWorkDir, - "--logs-dir", sLogDir, - "--key-path", sKeyPath, - "--cert-path", sCertPath, - "--chain-path", sChainPath, - "--fullchain-path", sFullCertPath, - "-d", sDomain, - "--csr", sCsrPath, - "--register-unsafely-without-email"); - - if (hFailure != null) { - return hFailure; + Http01Challenge challenge = auth.findChallenge(Http01Challenge.class) + .orElseThrow(() -> new AcmeException( + "No HTTP-01 challenge available for " + sDomain)); + + File challengeDir = new File(dirChallenge, + ".well-known" + File.separator + "acme-challenge"); + if (!challengeDir.exists() && !challengeDir.mkdirs()) { + throw new IOException("Cannot create challenge directory: " + challengeDir); } - // convert certificates from "pem" to "pkcs12" format - String sTempStorePath = sCertsDir + File.separator + sName + ".p12"; - hFailure = runCommand(frame, null, - "openssl", "pkcs12", "-export", - "-out", sTempStorePath, - "-inkey", sKeyPath, - "-in", sFullCertPath, - "-name", sName, - "-passin", "pass:" + hPwd.getStringValue(), - "-passout", "pass:" + hPwd.getStringValue() - ); - - if (hFailure != null) { - return hFailure; + File challengeFile = new File(challengeDir, challenge.getToken()); + try (var writer = new FileWriter(challengeFile)) { + writer.write(challenge.getAuthorization()); } - // transfer the key-pair into the target keystore - hFailure = runCommand(frame, null, - "keytool", "-importkeystore", - "-srckeystore", sTempStorePath, - "-srcstoretype", "PKCS12", - "-destkeystore", hStorePath.getStringValue(), - "-deststoretype", "PKCS12", - "-alias", sName, - "-srcstorepass", hPwd.getStringValue(), - "-deststorepass", hPwd.getStringValue() - ); - - new File(sTempStorePath).delete(); // it's encrypted, but still no reason to leave - return hFailure; - } finally { try { - // no matter what; don't leave the unencrypted key file - new File(sKeyPath).delete(); - } catch (Exception ignore) {} + challenge.trigger(); + Status authStatus = auth.waitForCompletion(ACME_TIMEOUT); + if (authStatus != Status.VALID) { + throw new AcmeException("Challenge failed for " + sDomain + + " (status: " + authStatus + ")"); + } + } finally { + challengeFile.delete(); + } } } + /** + * Build a CSR from the distinguished name string. + */ + private CSRBuilder buildCSR(String sDName, String sDomain) { + var csrBuilder = new CSRBuilder(); + csrBuilder.addDomain(sDomain); + + for (var sPart : sDName.split(",")) { + var kv = sPart.trim().split("=", 2); + var key = kv[0]; + var value = kv.length > 1 ? kv[1] : ""; + + switch (key) { + case "C" -> csrBuilder.setCountry(value); + case "ST", "S" -> csrBuilder.setState(value); + case "L" -> csrBuilder.setLocality(value); + case "O" -> csrBuilder.setOrganization(value); + case "OU" -> csrBuilder.setOrganizationalUnit(value); + default -> {} // CN handled separately, ignore unknown + } + } + return csrBuilder; + } + + + // ----- certificate revocation ---------------------------------------------------------------- + /** * Native implementation of * "revokeCertificateImpl(String path, Password pwd, String name)" + *

+ * Replaces the old native flow: + *

{@code
+     *   certbot revoke --config-dir /config --cert-name  --reason unspecified
+     *   keytool -delete -alias  -keystore  -storepass 
+     * }
+ * The old certbot revocation used the stored account key from its config directory. + * The Java implementation uses domain-key revocation (RFC 8555 §7.6) — extracting + * the domain keypair from the keystore, which is more robust because it doesn't + * depend on certbot's external config state. */ - private ExceptionHandle invokeRevokeCertificate(Frame frame, ServiceHandle hMgr, ObjectHandle[] ahArg) { - StringHandle hPath = (StringHandle) ahArg[0]; - StringHandle hPwd = xRTKeyStore.getPassword(frame, ahArg[1]); - StringHandle hName = (StringHandle) ahArg[2]; - StringHandle hProvider = (StringHandle) hMgr.getField(0); // "provider" property - - File dirCerts = getCertsPath(hPath); - if (dirCerts.isDirectory()) { - String sCertsDir = dirCerts.getAbsolutePath(); - - switch (hProvider.getStringValue()) { - case "self": - break; - - case "certbot-staging": - runCommand(frame, "yes\nyes", - "certbot", "revoke", - "--staging", - "--config-dir", sCertsDir + File.separator + "config", - "--work-dir", sCertsDir + File.separator + "work", - "--logs-dir", sCertsDir + File.separator + "logs", - "--cert-name", hName.getStringValue(), - "--reason", "unspecified" - ); - break; - - case "certbot": - runCommand(frame, "yes\nyes", - "certbot", "revoke", - "--config-dir", sCertsDir + File.separator + "config", - "--work-dir", sCertsDir + File.separator + "work", - "--logs-dir", sCertsDir + File.separator + "logs", - "--cert-name", hName.getStringValue(), - "--reason", "unspecified" - ); - break; - - default: - return xException.makeHandle(frame, - "Unsupported certificate provider: " + hProvider.getStringValue()); + private ExceptionHandle invokeRevokeCertificate(Frame frame, ServiceHandle hMgr, + ObjectHandle[] ahArg) { + String sPath = ((StringHandle) ahArg[0]).getStringValue(); + char[] achPwd = xRTKeyStore.getPassword(frame, ahArg[1]).getValue(); + String sName = ((StringHandle) ahArg[2]).getStringValue(); + String sProvider = ((StringHandle) hMgr.getField(0)).getStringValue(); + + try { + switch (sProvider) { + case "self" -> {} + case "certbot-staging" -> revokeWithAcme(sPath, achPwd, sName, true); + case "certbot" -> revokeWithAcme(sPath, achPwd, sName, false); + default -> { + return xException.makeHandle(frame, + "Unsupported certificate provider: " + sProvider); + } } - } - runSilentCommand( - "keytool", "-delete", - "-alias", hName.getStringValue(), - "-keystore", hPath.getStringValue(), - "-storepass", hPwd.getStringValue() - ); - return null; + KeyStoreOperations.deleteKeyStoreEntry(sPath, achPwd, sName); + return null; + } catch (AcmeException | GeneralSecurityException | IOException e) { + return xException.obscureIoException(frame, e.getMessage()); + } } - private File getCertsPath(StringHandle hPath) { - File fileKeystore = Path.of(hPath.getStringValue()).toFile(); - return new File(fileKeystore.getParentFile(), ".certs"); - } + /** + * Revoke a certificate using the ACME protocol via acme4j. + *

+ * Uses domain-key-authenticated revocation: the private key that signed the CSR is extracted + * from the keystore and used to prove ownership to the ACME server. This is one of two + * revocation mechanisms defined in RFC 8555 §7.6 (the other being account-key revocation). + * We use domain-key revocation because the account keypair is ephemeral (generated fresh per + * certificate request) and not persisted, whereas the domain key is always in the keystore + * alongside the certificate. + */ + private void revokeWithAcme(String sStorePath, char[] achPwd, String sName, boolean fStaging) + throws AcmeException, GeneralSecurityException, IOException { + KeyStore keyStore = KeyStoreOperations.loadOrCreateKeyStore(sStorePath, achPwd); + Certificate cert = keyStore.getCertificate(sName); + + if (cert instanceof X509Certificate x509Cert) { + Key privateKey = keyStore.getKey(sName, achPwd); + if (privateKey == null) { + throw new AcmeException("Cannot revoke certificate '" + sName + + "': private key not found in keystore"); + } - private File getChallengePath(StringHandle hPath) { - File fileKeystore = Path.of(hPath.getStringValue()).toFile(); - return new File(fileKeystore.getParentFile(), ".challenge"); + KeyPair domainKeyPair = new KeyPair(x509Cert.getPublicKey(), (PrivateKey) privateKey); + Session session = new Session(acmeServerUri(fStaging)); + + org.shredzone.acme4j.Certificate.revoke(session, domainKeyPair, x509Cert, null); + } } + + // ----- symmetric key & password management --------------------------------------------------- + /** * Native implementation of * "invokeCreateSymmetricKeyImpl(String path, Password pwd, String name)" + *

+ * Replaces: + *

{@code
+     *   keytool -delete -alias  -keystore  -storepass 
+     *   keytool -genseckey -keyalg AES -keysize 256 -alias 
+     *           -storetype PKCS12 -keystore  -storepass 
+     * }
+ * Uses {@link javax.crypto.KeyGenerator#getInstance(String)} with AES/256 — the same + * JDK API that keytool's {@code -genseckey} uses internally. The resulting + * {@code SecretKeyEntry} in the PKCS12 keystore is identical in format. */ private ExceptionHandle invokeCreateSymmetricKey(Frame frame, ObjectHandle[] ahArg) { - StringHandle hPath = (StringHandle) ahArg[0]; - StringHandle hPwd = xRTKeyStore.getPassword(frame, ahArg[1]); - StringHandle hName = (StringHandle) ahArg[2]; - - runSilentCommand( - "keytool", "-delete", - "-alias", hName.getStringValue(), - "-keystore", hPath.getStringValue(), - "-storepass", hPwd.getStringValue() - ); - return runNoInputCommand(frame, - "keytool", "-genseckey", "-keyalg", "AES", "-keysize", "256", - "-alias", hName.getStringValue(), - "-storetype", "PKCS12", - "-keystore", hPath.getStringValue(), - "-storepass", hPwd.getStringValue() - ); + String sPath = ((StringHandle) ahArg[0]).getStringValue(); + char[] achPwd = xRTKeyStore.getPassword(frame, ahArg[1]).getValue(); + String sName = ((StringHandle) ahArg[2]).getStringValue(); + + try { + KeyStoreOperations.createSymmetricKey(sPath, achPwd, sName); + return null; + } catch (GeneralSecurityException | IOException e) { + return xException.obscureIoException(frame, e.getMessage()); + } } /** * Native implementation of * "invokeCreatePasswordImpl(String path, Password pwd, String name, String pwdValue)" + *

+ * Replaces: + *

{@code
+     *   keytool -delete -alias  -keystore  -storepass 
+     *   echo  | keytool -importpass -alias  -storetype PKCS12
+     *           -keystore  -storepass 
+     * }
+ * Uses {@link javax.crypto.SecretKeyFactory#getInstance(String)} with "PBE" to create + * a PBE secret key from the password value, then stores it as a {@code SecretKeyEntry} + * — the same internal representation that keytool's {@code -importpass} produces. */ private ExceptionHandle invokeCreatePassword(Frame frame, ObjectHandle[] ahArg) { - StringHandle hPath = (StringHandle) ahArg[0]; - StringHandle hPwd = xRTKeyStore.getPassword(frame, ahArg[1]); - StringHandle hName = (StringHandle) ahArg[2]; - StringHandle hPwdValue = (StringHandle) ahArg[3]; - - runSilentCommand( - "keytool", "-delete", - "-alias", hName.getStringValue(), - "-keystore", hPath.getStringValue(), - "-storepass", hPwd.getStringValue() - ); - return runCommand(frame, hPwdValue.getStringValue(), - "keytool", "-importpass", - "-alias", hName.getStringValue(), - "-storetype", "PKCS12", - "-keystore", hPath.getStringValue(), - "-storepass", hPwd.getStringValue() - ); + String sPath = ((StringHandle) ahArg[0]).getStringValue(); + char[] achPwd = xRTKeyStore.getPassword(frame, ahArg[1]).getValue(); + String sName = ((StringHandle) ahArg[2]).getStringValue(); + String sPwdValue = ((StringHandle) ahArg[3]).getStringValue(); + + try { + KeyStoreOperations.createPassword(sPath, achPwd, sName, sPwdValue); + return null; + } catch (GeneralSecurityException | IOException e) { + return xException.obscureIoException(frame, e.getMessage()); + } } + + // ----- key extraction & password change ------------------------------------------------------ + /** * Native implementation of * "Byte[] extractKeyImpl(String|KeyStore pathOrStore, Password pwd, String name)" @@ -459,43 +495,38 @@ private int invokeExtractKey(Frame frame, ObjectHandle[] ahArg, int iReturn) { CompletableFuture cfResult = frame.f_context.f_container.scheduleIO( () -> loadKey(hPathOrStore, hPwd, hName)); + Frame.Continuation continuation = frameCaller -> { try { Key key = cfResult.get(); return key == null - ? frameCaller.raiseException(xException.ioException(frameCaller, - "Invalid or inaccessible key \"" + hName.getStringValue() + '"')) - : frameCaller.assignValue(iReturn, - xArray.makeByteArrayHandle(key.getEncoded(), Mutability.Constant)); - } catch (Throwable e) { - return frameCaller.raiseException("Unexpected execution failure " + e); + ? frameCaller.raiseException(xException.ioException(frameCaller, + "Invalid or inaccessible key \"" + hName.getStringValue() + '"')) + : frameCaller.assignValue(iReturn, + xArray.makeByteArrayHandle(key.getEncoded(), Mutability.Constant)); + } catch (InterruptedException | ExecutionException e) { + // TODO: we temporarily print the stack trace for unhandled exceptions here; remove + e.printStackTrace(); + return frameCaller.raiseException( + xException.makeObscure(frame, "Unexpected execution failure " + e)); } }; - return frame.waitForIO(cfResult, continuation); } - private Key loadKey(ObjectHandle hPathOrStore, StringHandle hPwd, StringHandle hName) { + private Key loadKey(ObjectHandle hPathOrStore, StringHandle hPwd, StringHandle hName) + throws GeneralSecurityException, IOException { char[] achPwd = hPwd.getValue(); String sKey = hName.getStringValue(); - try { - KeyStore keyStore; - if (hPathOrStore instanceof StringHandle hPath) { - File fileStore = new File(hPath.getStringValue()); - keyStore = KeyStore.getInstance("PKCS12"); - keyStore.load(new FileInputStream(fileStore), achPwd); - } else { - KeyStoreHandle hKeyStore = (KeyStoreHandle) hPathOrStore; - keyStore = hKeyStore.f_keyStore; - } - - return keyStore.getKey(sKey, achPwd); - } catch (Exception e) { - System.err.println(Handy.logTime() + " [Debug]: Failed to load key: " + sKey + - " (" + e.getMessage() + ")"); - return null; + KeyStore keyStore; + if (hPathOrStore instanceof StringHandle hPath) { + keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(new FileInputStream(hPath.getStringValue()), achPwd); + } else { + keyStore = ((KeyStoreHandle) hPathOrStore).f_keyStore; } + return keyStore.getKey(sKey, achPwd); } /** @@ -512,102 +543,40 @@ private int invokeKeystoreFor(Frame frame, ObjectHandle[] ahArg, int iReturn) { /** * Native implementation of - * "encryptKeystoreImpl(String path, Password pwd, String newPwd)" + * "encryptKeyStoreImpl(String path, Password pwd, String newPwd)" + *

+ * Loads the keystore with the old password and saves with the new one — the same + * operation that keytool's {@code -storepasswd} performs internally via the JDK + * {@link java.security.KeyStore} API. */ private ExceptionHandle invokeEncryptKeystore(Frame frame, ObjectHandle[] ahArg) { - StringHandle hPath = (StringHandle) ahArg[0]; - StringHandle hPwd = xRTKeyStore.getPassword(frame, ahArg[1]); - StringHandle hPwdNew = (StringHandle) ahArg[2]; - - return runNoInputCommand(frame, - "keytool", "-storepasswd", - "-keystore", hPath.getStringValue(), - "-storepass", hPwd.getStringValue(), - "-new ", hPwdNew.getStringValue() - ); - } - - private ExceptionHandle runSilentCommand(String... cmd) { - return runCommand(null, null, cmd); - } - - private ExceptionHandle runNoInputCommand(Frame frame, String... cmd) { - return runCommand(frame, null, cmd); - } + String sPath = ((StringHandle) ahArg[0]).getStringValue(); + char[] achPwd = xRTKeyStore.getPassword(frame, ahArg[1]).getValue(); + char[] achPwdNew = ((StringHandle) ahArg[2]).getValue(); - /** - * @return an exception handler or null if operation succeeded - */ - private ExceptionHandle runCommand(Frame frame, String sInput, String... cmd) { - // *** IMPORTANT SECURITY NOTE***: - // ProcessBuilder does not invoke a shell by default, and we should never take the command - // itself (i.e. cmd[0]) from a passed-in argument, which then removes the risk of a shell - // injection attack. - ProcessBuilder builder = new ProcessBuilder(cmd); try { - // TODO: remove - System.out.println(Handy.logTime() + " Trace: running command: " + toString(cmd)); - - Process process = builder.start(); - if (sInput != null) { - OutputStream out = process.getOutputStream(); - out.write(sInput.getBytes()); - out.close(); - } - - if (!process.waitFor(300, TimeUnit.SECONDS)) { - process.destroy(); - return xException.timedOut(frame, "Timed out: " + cmd[0], xNullable.NULL); - } - - if (frame != null && process.exitValue() != 0) { - String sOut = getOutput(process.getInputStream()); - String sErr = getOutput(process.getErrorStream()); - - return xException.obscureIoException(frame, sOut + '\n' + sErr); - } - + KeyStoreOperations.changeStorePassword(sPath, achPwd, achPwdNew); return null; - } catch (Exception e) { - return frame == null ? null : xException.makeObscure(frame, e.getMessage()); + } catch (Exception e) { // TODO: tighten to GeneralSecurityException | IOException + return xException.obscureIoException(frame, e.getMessage()); } } - /** - * Get a message from the specified input stream. - * - * @return an error message - */ - private String getOutput(InputStream streamIn) { - BufferedReader reader = new BufferedReader(new InputStreamReader(streamIn)); - StringBuilder sb = new StringBuilder(); - try { - String sLine; - while ((sLine = reader.readLine()) != null) { - if (!sb.isEmpty()) { - sb.append('\n'); - } - sb.append(sLine); - } - } catch (IOException ignore) {} - return sb.toString(); + // ----- helper methods ------------------------------------------------------------------------ + + private static String acmeServerUri(boolean fStaging) { + return fStaging ? "acme://letsencrypt.org/staging" : "acme://letsencrypt.org"; } - private String toString(String... cmd) { - StringBuilder sb = new StringBuilder(); - for (String s : cmd) { - sb.append(' ') - .append(s); - } - return sb.substring(1); + private File getChallengePath(StringHandle hPath) { + return new File(Path.of(hPath.getStringValue()).toFile().getParentFile(), ".challenge"); } // ----- data fields and constants ------------------------------------------------------------- - /** - * Cached canonical type. - */ + private static final Duration ACME_TIMEOUT = Duration.ofMinutes(2); + private TypeConstant m_typeCanonical; -} \ No newline at end of file +} diff --git a/javatools/src/test/java/org/xvm/runtime/template/_native/crypto/KeyStoreCompatibilityTest.java b/javatools/src/test/java/org/xvm/runtime/template/_native/crypto/KeyStoreCompatibilityTest.java new file mode 100644 index 0000000000..1b1064885d --- /dev/null +++ b/javatools/src/test/java/org/xvm/runtime/template/_native/crypto/KeyStoreCompatibilityTest.java @@ -0,0 +1,307 @@ +package org.xvm.runtime.template._native.crypto; + + +import java.io.File; + +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.cert.X509Certificate; + +import java.util.concurrent.TimeUnit; + +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** + * Drop-in compatibility tests that verify the pure Java implementation produces keystores + * and entries that are interchangeable with those created by the native keytool/openssl + * commands. + *

+ * Tests annotated with {@code @EnabledIf("isKeytoolAvailable")} run only when keytool is + * on the PATH. Tests annotated with {@code @EnabledIf("isOpensslAvailable")} run only + * when openssl is on the PATH. + */ +public class KeyStoreCompatibilityTest { + + private static final char[] PASSWORD = "compat-test".toCharArray(); + private static final String PASSWORD_STR = "compat-test"; + + @TempDir + File tempDir; + + // ----- keytool cross-compatibility tests ---------------------------------------------------- + + /** + * Create a keystore with keytool, then verify our Java code can read and extract its + * contents correctly. This proves our code is a drop-in reader for keytool output. + */ + @Test + @EnabledIf("isKeytoolAvailable") + public void testJavaReadsKeytoolSelfSignedCert() throws Exception { + var path = new File(tempDir, "keytool-created.p12").getAbsolutePath(); + + // create a self-signed cert using keytool (the old way) + var exitCode = new ProcessBuilder( + "keytool", "-genkeypair", "-keyalg", "RSA", "-keysize", "2048", "-validity", "90", + "-alias", "testcert", + "-dname", "CN=compat.example.com,O=Test,C=US", + "-storetype", "PKCS12", + "-keystore", path, + "-storepass", PASSWORD_STR + ).redirectErrorStream(true).start().waitFor(); + assertEquals(0, exitCode, "keytool command failed"); + + // read the keytool-created keystore using our Java code + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertTrue(keyStore.containsAlias("testcert")); + + var cert = (X509Certificate) keyStore.getCertificate("testcert"); + assertNotNull(cert); + assertTrue(cert.getSubjectX500Principal().getName().contains("CN=compat.example.com")); + + var key = KeyStoreOperations.extractKey(path, PASSWORD, "testcert"); + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } + + /** + * Create a keystore with our Java code, then verify keytool can read and list its + * contents. This proves our output is a drop-in replacement for keytool output. + */ + @Test + @EnabledIf("isKeytoolAvailable") + public void testKeytoolReadsJavaSelfSignedCert() throws Exception { + var path = new File(tempDir, "java-created.p12").getAbsolutePath(); + + // create a self-signed cert using our Java code (the new way) + KeyStoreOperations.createSelfSignedCertificate( + path, PASSWORD, "testcert", "CN=compat.example.com,O=Test,C=US"); + + // verify keytool can read it + var process = new ProcessBuilder( + "keytool", "-list", "-v", + "-keystore", path, + "-storepass", PASSWORD_STR, + "-alias", "testcert" + ).redirectErrorStream(true).start(); + + var output = new String(process.getInputStream().readAllBytes()); + assertTrue(process.waitFor(10, TimeUnit.SECONDS)); + assertEquals(0, process.exitValue(), "keytool -list failed: " + output); + assertTrue(output.contains("compat.example.com"), "keytool should see our CN"); + assertTrue(output.contains("RSA"), "keytool should see RSA key type"); + } + + /** + * Create an AES symmetric key with keytool, verify our Java code can extract it. + * Then create one with Java and verify keytool can read it. + */ + @Test + @EnabledIf("isKeytoolAvailable") + public void testSymmetricKeyInterop() throws Exception { + var keytoolPath = new File(tempDir, "keytool-sym.p12").getAbsolutePath(); + var javaPath = new File(tempDir, "java-sym.p12").getAbsolutePath(); + + // keytool creates symmetric key + var exitCode = new ProcessBuilder( + "keytool", "-genseckey", "-keyalg", "AES", "-keysize", "256", + "-alias", "aeskey", + "-storetype", "PKCS12", + "-keystore", keytoolPath, + "-storepass", PASSWORD_STR + ).redirectErrorStream(true).start().waitFor(); + assertEquals(0, exitCode, "keytool -genseckey failed"); + + // Java reads keytool's key + var keytoolKey = KeyStoreOperations.extractKey(keytoolPath, PASSWORD, "aeskey"); + assertNotNull(keytoolKey, "should be able to read keytool's AES key"); + assertEquals("AES", keytoolKey.getAlgorithm()); + assertEquals(32, keytoolKey.getEncoded().length, "should be 256-bit"); + + // Java creates symmetric key + KeyStoreOperations.createSymmetricKey(javaPath, PASSWORD, "aeskey"); + + // keytool reads Java's key + var process = new ProcessBuilder( + "keytool", "-list", "-v", + "-keystore", javaPath, + "-storepass", PASSWORD_STR, + "-alias", "aeskey" + ).redirectErrorStream(true).start(); + + var output = new String(process.getInputStream().readAllBytes()); + assertTrue(process.waitFor(10, TimeUnit.SECONDS)); + assertEquals(0, process.exitValue(), "keytool can't read Java's AES key: " + output); + } + + // ----- openssl cross-compatibility tests ---------------------------------------------------- + + /** + * Create a self-signed cert with our Java code, export the private key, and verify + * openssl can parse it. This proves our keystore entries are compatible with openssl + * PKCS12 handling. + */ + @Test + @EnabledIf("isOpensslAvailable") + public void testOpensslReadJavaKeystore() throws Exception { + var path = new File(tempDir, "openssl-test.p12").getAbsolutePath(); + + KeyStoreOperations.createSelfSignedCertificate( + path, PASSWORD, "sslcert", "CN=ssl.example.com"); + + // openssl should be able to parse our PKCS12 keystore + var process = new ProcessBuilder( + "openssl", "pkcs12", "-in", path, "-passin", "pass:" + PASSWORD_STR, + "-nokeys", "-info" + ).redirectErrorStream(true).start(); + + var output = new String(process.getInputStream().readAllBytes()); + assertTrue(process.waitFor(10, TimeUnit.SECONDS)); + assertEquals(0, process.exitValue(), "openssl can't read Java's PKCS12: " + output); + } + + // ----- platform layout tests (no native tools needed) --------------------------------------- + + /** + * Verify that the delete-then-recreate pattern works correctly — the platform relies + * on this for certificate renewal. + */ + @Test + public void testDeleteAndRecreatePattern() throws Exception { + var path = new File(tempDir, "renewal.p12").getAbsolutePath(); + + // create initial cert + KeyStoreOperations.createSelfSignedCertificate( + path, PASSWORD, "host", "CN=host.example.com"); + + var keyStore1 = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + var cert1 = (X509Certificate) keyStore1.getCertificate("host"); + assertNotNull(cert1); + + // delete and recreate (simulates certificate renewal) + KeyStoreOperations.deleteKeyStoreEntry(path, PASSWORD, "host"); + KeyStoreOperations.createSelfSignedCertificate( + path, PASSWORD, "host", "CN=host.example.com"); + + var keyStore2 = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + var cert2 = (X509Certificate) keyStore2.getCertificate("host"); + assertNotNull(cert2); + + // new cert should be different (new keypair) + assertNotEquals(cert1.getSerialNumber(), cert2.getSerialNumber(), + "renewed cert should have different serial"); + + // but same subject + assertEquals(cert1.getSubjectX500Principal(), cert2.getSubjectX500Principal()); + } + + /** + * Verify that keystores with multiple entry types (cert + symmetric keys + passwords) + * work correctly — this mirrors the platform's actual keystore layout (TLS cert + + * CookieEncryptionKey + PasswordEncryptionKey). + */ + @Test + public void testPlatformKeystoreLayout() throws Exception { + var path = new File(tempDir, "platform.p12").getAbsolutePath(); + + // create the same layout as the platform: TLS cert + two encryption keys + KeyStoreOperations.createSelfSignedCertificate( + path, PASSWORD, "PlatformTlsKey", "CN=platform.example.com"); + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "CookieEncryptionKey"); + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "PasswordEncryptionKey"); + + // reload and verify everything is accessible + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertEquals(3, keyStore.size()); + + // TLS cert: RSA keypair + X509 certificate + assertTrue(keyStore.isKeyEntry("PlatformTlsKey")); + var cert = (X509Certificate) keyStore.getCertificate("PlatformTlsKey"); + assertNotNull(cert); + cert.checkValidity(); + var tlsKey = keyStore.getKey("PlatformTlsKey", PASSWORD); + assertNotNull(tlsKey); + assertEquals("RSA", tlsKey.getAlgorithm()); + + // Cookie encryption: AES-256 + var cookieKey = (SecretKey) keyStore.getKey("CookieEncryptionKey", PASSWORD); + assertNotNull(cookieKey); + assertEquals("AES", cookieKey.getAlgorithm()); + assertEquals(32, cookieKey.getEncoded().length); + + // Password encryption: AES-256 + var pwdKey = (SecretKey) keyStore.getKey("PasswordEncryptionKey", PASSWORD); + assertNotNull(pwdKey); + assertEquals("AES", pwdKey.getAlgorithm()); + assertEquals(32, pwdKey.getEncoded().length); + } + + /** + * Verify that a domain keypair stored during certificate creation can be reconstructed + * for revocation — the exact pattern used by revokeWithAcme(). + */ + @Test + public void testDomainKeyPairRoundTrip() throws Exception { + var path = new File(tempDir, "keypair.p12").getAbsolutePath(); + + // create a self-signed cert (stores private key in keystore, same as ACME flow) + KeyStoreOperations.createSelfSignedCertificate( + path, PASSWORD, "domain", "CN=domain.example.com"); + + // reconstruct the keypair from keystore (same as revokeWithAcme does) + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + var cert = (X509Certificate) keyStore.getCertificate("domain"); + var privateKey = keyStore.getKey("domain", PASSWORD); + assertNotNull(privateKey, "private key should be in keystore"); + + var domainKeyPair = new KeyPair( + cert.getPublicKey(), + (java.security.PrivateKey) privateKey); + + // verify the keypair is consistent: sign something and verify it + var signer = java.security.Signature.getInstance("SHA256withRSA"); + var testData = "test data for signing".getBytes(); + + signer.initSign(domainKeyPair.getPrivate()); + signer.update(testData); + var signature = signer.sign(); + + signer.initVerify(domainKeyPair.getPublic()); + signer.update(testData); + assertTrue(signer.verify(signature), + "reconstructed keypair should produce valid signatures"); + } + + // ----- helper methods ----------------------------------------------------------------------- + + static boolean isKeytoolAvailable() { + try { + var process = new ProcessBuilder("keytool", "-help") + .redirectErrorStream(true).start(); + process.getInputStream().readAllBytes(); + return process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0; + } catch (Exception e) { + return false; + } + } + + static boolean isOpensslAvailable() { + try { + var process = new ProcessBuilder("openssl", "version") + .redirectErrorStream(true).start(); + process.getInputStream().readAllBytes(); + return process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0; + } catch (Exception e) { + return false; + } + } +} diff --git a/javatools/src/test/java/org/xvm/runtime/template/_native/crypto/KeyStoreOperationsTest.java b/javatools/src/test/java/org/xvm/runtime/template/_native/crypto/KeyStoreOperationsTest.java new file mode 100644 index 0000000000..c103563181 --- /dev/null +++ b/javatools/src/test/java/org/xvm/runtime/template/_native/crypto/KeyStoreOperationsTest.java @@ -0,0 +1,220 @@ +package org.xvm.runtime.template._native.crypto; + + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import java.security.KeyStore; +import java.security.cert.X509Certificate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** + * Tests for {@link KeyStoreOperations} — pure Java keystore and certificate operations + * with no XVM runtime dependencies. + */ +public class KeyStoreOperationsTest { + + private static final char[] PASSWORD = "testpass".toCharArray(); + + @TempDir + File tempDir; + + @Test + public void testCreateAndLoadKeyStore() throws Exception { + var path = new File(tempDir, "test.p12").getAbsolutePath(); + + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertNotNull(keyStore); + assertEquals(0, keyStore.size()); + + KeyStoreOperations.saveKeyStore(keyStore, path, PASSWORD); + assertTrue(new File(path).exists()); + + var reloaded = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertEquals(0, reloaded.size()); + } + + @Test + public void testCreateSelfSignedCertificate() throws Exception { + var path = new File(tempDir, "cert.p12").getAbsolutePath(); + + KeyStoreOperations.createSelfSignedCertificate( + path, PASSWORD, "myalias", "CN=test.example.com,O=Test Corp,C=US"); + + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertTrue(keyStore.containsAlias("myalias")); + assertTrue(keyStore.isKeyEntry("myalias")); + + var cert = (X509Certificate) keyStore.getCertificate("myalias"); + assertNotNull(cert); + assertEquals("SHA256WITHRSA", cert.getSigAlgName().toUpperCase()); + + var subject = cert.getSubjectX500Principal().getName(); + assertTrue(subject.contains("CN=test.example.com")); + + var key = keyStore.getKey("myalias", PASSWORD); + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } + + @Test + public void testCreateSymmetricKey() throws Exception { + var path = new File(tempDir, "sym.p12").getAbsolutePath(); + + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "aeskey"); + + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertTrue(keyStore.containsAlias("aeskey")); + + var key = keyStore.getKey("aeskey", PASSWORD); + assertNotNull(key); + assertEquals("AES", key.getAlgorithm()); + assertEquals(32, key.getEncoded().length); // 256 bits + } + + @Test + public void testCreateSymmetricKeyReplacesExisting() throws Exception { + var path = new File(tempDir, "replace.p12").getAbsolutePath(); + + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "key1"); + var keyStore1 = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + var key1 = keyStore1.getKey("key1", PASSWORD).getEncoded(); + + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "key1"); + var keyStore2 = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + var key2 = keyStore2.getKey("key1", PASSWORD).getEncoded(); + + // keys should be different (regenerated) + assertFalse(Arrays.equals(key1, key2)); + } + + @Test + public void testCreatePassword() throws Exception { + var path = new File(tempDir, "pwd.p12").getAbsolutePath(); + + KeyStoreOperations.createPassword(path, PASSWORD, "dbpass", "s3cret!"); + + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertTrue(keyStore.containsAlias("dbpass")); + + var key = keyStore.getKey("dbpass", PASSWORD); + assertNotNull(key); + assertTrue(key.getAlgorithm().startsWith("PBE")); + } + + @Test + public void testChangeStorePassword() throws Exception { + var path = new File(tempDir, "changepwd.p12").getAbsolutePath(); + var newPwd = "newpass".toCharArray(); + + // create a keystore with an entry + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "mykey"); + + // change the password + KeyStoreOperations.changeStorePassword(path, PASSWORD, newPwd); + + // old password should fail + var keyStore = KeyStore.getInstance("PKCS12"); + assertThrows(Exception.class, () -> { + try (var in = new FileInputStream(path)) { + keyStore.load(in, PASSWORD); + } + }); + + // new password should work + var reloaded = KeyStoreOperations.loadOrCreateKeyStore(path, newPwd); + assertTrue(reloaded.containsAlias("mykey")); + } + + @Test + public void testExtractKey() throws Exception { + var path = new File(tempDir, "extract.p12").getAbsolutePath(); + + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "extractme"); + + var key = KeyStoreOperations.extractKey(path, PASSWORD, "extractme"); + assertNotNull(key); + assertEquals("AES", key.getAlgorithm()); + } + + @Test + public void testExtractKeyReturnsNullForMissingAlias() throws Exception { + var path = new File(tempDir, "nokey.p12").getAbsolutePath(); + + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "exists"); + + assertNull(KeyStoreOperations.extractKey(path, PASSWORD, "doesnotexist")); + } + + @Test + public void testExtractKeyReturnsNullForMissingFile() { + assertThrows(IOException.class, () -> + KeyStoreOperations.extractKey("/nonexistent/path.p12", PASSWORD, "key")); + } + + @Test + public void testDeleteKeyStoreEntry() throws Exception { + var path = new File(tempDir, "del.p12").getAbsolutePath(); + + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "todelete"); + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "tokeep"); + + var before = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertTrue(before.containsAlias("todelete")); + + KeyStoreOperations.deleteKeyStoreEntry(path, PASSWORD, "todelete"); + + var after = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertFalse(after.containsAlias("todelete")); + assertTrue(after.containsAlias("tokeep")); + } + + @Test + public void testDeleteKeyStoreEntryNonexistentAlias() throws Exception { + var path = new File(tempDir, "delnone.p12").getAbsolutePath(); + + KeyStoreOperations.createSymmetricKey(path, PASSWORD, "exists"); + + // should not throw + KeyStoreOperations.deleteKeyStoreEntry(path, PASSWORD, "nosuchalias"); + + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + assertTrue(keyStore.containsAlias("exists")); + } + + @Test + public void testDeleteKeyStoreEntryNonexistentFile() { + // should not throw + KeyStoreOperations.deleteKeyStoreEntry("/nonexistent/path.p12", PASSWORD, "alias"); + } + + @Test + public void testSelfSignedCertificateValidity() throws Exception { + var path = new File(tempDir, "validity.p12").getAbsolutePath(); + + KeyStoreOperations.createSelfSignedCertificate( + path, PASSWORD, "cert", "CN=valid.example.com"); + + var keyStore = KeyStoreOperations.loadOrCreateKeyStore(path, PASSWORD); + var cert = (X509Certificate) keyStore.getCertificate("cert"); + + // certificate should be valid right now + cert.checkValidity(); + + // verify it was self-signed (issuer == subject) + assertEquals(cert.getSubjectX500Principal(), cert.getIssuerX500Principal()); + } +}