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
+ * 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
+ * For provider "self", replaces:
+ *
+ * For providers "certbot"/"certbot-staging", replaces the multistep native flow:
+ *
+ * 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
+ * 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
+ * Replaces the old native flow:
+ *
+ * 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:
+ *
+ * Replaces:
+ *
+ * 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());
+ }
+}
{@code
+ * keytool -delete -alias
+ * 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.
+ * {@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.
+ * {@code
+ * certbot revoke --config-dir
+ * 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.
+ * {@code
+ * keytool -delete -alias
+ * 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)"
+ * {@code
+ * keytool -delete -alias
+ * 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