diff --git a/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/KeyStoreLoader.java b/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/KeyStoreLoader.java index 89ea40ec98..e972a3a255 100644 --- a/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/KeyStoreLoader.java +++ b/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/KeyStoreLoader.java @@ -11,9 +11,9 @@ package org.eclipse.milo.examples.server; import com.google.common.collect.Sets; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; import java.nio.file.Path; import java.security.Key; import java.security.KeyPair; @@ -48,11 +48,11 @@ class KeyStoreLoader { KeyStoreLoader load(Path baseDir) throws Exception { KeyStore keyStore = KeyStore.getInstance("PKCS12"); - File serverKeyStore = baseDir.resolve("example-server.pfx").toFile(); + Path serverKeyStore = baseDir.resolve("example-server.pfx"); logger.info("Loading KeyStore at {}", serverKeyStore); - if (!serverKeyStore.exists()) { + if (!Files.exists(serverKeyStore)) { keyStore.load(null, PASSWORD); KeyPair keyPair = SelfSignedCertificateGenerator.generateRsaKeyPair(2048); @@ -86,9 +86,13 @@ KeyStoreLoader load(Path baseDir) throws Exception { keyStore.setKeyEntry( SERVER_ALIAS, keyPair.getPrivate(), PASSWORD, new X509Certificate[] {certificate}); - keyStore.store(new FileOutputStream(serverKeyStore), PASSWORD); + try (OutputStream out = Files.newOutputStream(serverKeyStore)) { + keyStore.store(out, PASSWORD); + } } else { - keyStore.load(new FileInputStream(serverKeyStore), PASSWORD); + try (InputStream in = Files.newInputStream(serverKeyStore)) { + keyStore.load(in, PASSWORD); + } } Key serverPrivateKey = keyStore.getKey(SERVER_ALIAS, PASSWORD); diff --git a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/KeyStoreLoader.java b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/KeyStoreLoader.java index 2da81b06ed..264338fa47 100644 --- a/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/KeyStoreLoader.java +++ b/opc-ua-sdk/integration-tests/src/test/java/org/eclipse/milo/opcua/sdk/test/KeyStoreLoader.java @@ -11,8 +11,10 @@ package org.eclipse.milo.opcua.sdk.test; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.Key; import java.security.KeyPair; import java.security.KeyStore; @@ -46,11 +48,11 @@ class KeyStoreLoader { KeyStoreLoader load(File baseDir) throws Exception { KeyStore keyStore = KeyStore.getInstance("PKCS12"); - File serverKeyStore = baseDir.toPath().resolve("example-server.pfx").toFile(); + Path serverKeyStore = baseDir.toPath().resolve("example-server.pfx"); LOGGER.debug("Loading KeyStore at {}", serverKeyStore); - if (!serverKeyStore.exists()) { + if (!Files.exists(serverKeyStore)) { keyStore.load(null, PASSWORD); KeyPair keyPair = SelfSignedCertificateGenerator.generateRsaKeyPair(2048); @@ -84,9 +86,13 @@ KeyStoreLoader load(File baseDir) throws Exception { keyStore.setKeyEntry( SERVER_ALIAS, keyPair.getPrivate(), PASSWORD, new X509Certificate[] {certificate}); - keyStore.store(new FileOutputStream(serverKeyStore), PASSWORD); + try (OutputStream out = Files.newOutputStream(serverKeyStore)) { + keyStore.store(out, PASSWORD); + } } else { - keyStore.load(new FileInputStream(serverKeyStore), PASSWORD); + try (InputStream in = Files.newInputStream(serverKeyStore)) { + keyStore.load(in, PASSWORD); + } } Key serverPrivateKey = keyStore.getKey(SERVER_ALIAS, PASSWORD); diff --git a/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/security/KeyStoreCertificateStore.java b/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/security/KeyStoreCertificateStore.java index 8de3759839..4e125a2da4 100644 --- a/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/security/KeyStoreCertificateStore.java +++ b/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/security/KeyStoreCertificateStore.java @@ -16,13 +16,16 @@ import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.FileSystems; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.security.Key; import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.X509Certificate; @@ -35,6 +38,7 @@ import java.util.function.Supplier; import org.eclipse.milo.opcua.stack.core.NodeIds; import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,10 +68,12 @@ public void initialize() throws Exception { keyStore = KeyStore.getInstance("pkcs12"); - File keyStoreFile = settings.keyStorePath.toFile(); + File keyStoreFile = settings.keyStorePath.toAbsolutePath().toFile(); if (keyStoreFile.exists()) { - keyStore.load(new FileInputStream(keyStoreFile), settings.getKeyStorePassword.get()); + try (var inputStream = new FileInputStream(keyStoreFile)) { + keyStore.load(inputStream, settings.getKeyStorePassword.get()); + } try { keyStoreLock.lock(); @@ -79,7 +85,7 @@ public void initialize() throws Exception { } else { keyStore.load(null, settings.getKeyStorePassword.get()); - keyStore.store(new FileOutputStream(keyStoreFile), settings.getKeyStorePassword.get()); + storeKeyStore(); } if (settings.watchForChanges) { @@ -172,17 +178,20 @@ public synchronized Entry remove(NodeId certificateTypeId) throws Exception { String alias = getAlias(certificateTypeId); if (alias != null) { - KeyStore.Entry entry = - keyStore.getEntry( - alias, new KeyStore.PasswordProtection(settings.getAliasPassword.apply(alias))); + char[] password = settings.getAliasPassword.apply(alias); + + KeyStore.Entry entry = keyStore.getEntry(alias, new KeyStore.PasswordProtection(password)); if (entry instanceof KeyStore.PrivateKeyEntry privateKeyEntry) { keyStore.deleteEntry(alias); entries.remove(alias); - keyStore.store( - new FileOutputStream(settings.keyStorePath.toFile()), - settings.getKeyStorePassword.get()); + try { + storeKeyStore(); + } catch (Exception e) { + restoreEntry(alias, entry, password, e); + throw e; + } return new Entry( privateKeyEntry.getPrivateKey(), @@ -209,11 +218,25 @@ public void set(NodeId certificateTypeId, Entry entry) throws Exception { String alias = getAlias(certificateTypeId); - keyStore.setKeyEntry( - alias, entry.privateKey, settings.getAliasPassword.apply(alias), entry.certificateChain); + if (alias == null) { + return; + } + + char[] password = settings.getAliasPassword.apply(alias); - keyStore.store( - new FileOutputStream(settings.keyStorePath.toFile()), settings.getKeyStorePassword.get()); + KeyStore.Entry previousEntry = + keyStore.isKeyEntry(alias) + ? keyStore.getEntry(alias, new KeyStore.PasswordProtection(password)) + : null; + + keyStore.setKeyEntry(alias, entry.privateKey, password, entry.certificateChain); + + try { + storeKeyStore(); + } catch (Exception e) { + restoreEntry(alias, previousEntry, password, e); + throw e; + } entries.put(alias, entry); } finally { @@ -225,9 +248,10 @@ public void set(NodeId certificateTypeId, Entry entry) throws Exception { * Get the alias to use when accessing certificates of type {@code certificateTypeId}. * * @param certificateTypeId the {@link NodeId} of the certificate type. - * @return the alias to use when accessing certificates of type {@code certificateTypeId}. + * @return the alias to use when accessing certificates of type {@code certificateTypeId}, or + * {@code null} if the certificate type is not supported. */ - protected String getAlias(NodeId certificateTypeId) { + protected @Nullable String getAlias(NodeId certificateTypeId) { if (certificateTypeId.equals(NodeIds.RsaSha256ApplicationCertificateType)) { return "server-rsa-sha256"; } else { @@ -248,6 +272,106 @@ protected void loadEntries() throws Exception { get(NodeIds.RsaSha256ApplicationCertificateType); } + /** + * Write the KeyStore to a temporary file in the same directory and then move it into place, + * replacing any existing file. + * + *
Opening the KeyStore file directly would truncate it before the new contents have been + * written, so a failure part way through the write would leave behind a KeyStore with no keys in + * it. + * + * @throws Exception if an error occurs while writing the KeyStore. + */ + private void storeKeyStore() throws Exception { + Path keyStorePath = resolveKeyStorePath(); + Path tempPath = Files.createTempFile(keyStorePath.getParent(), ".keystore", ".tmp"); + + try { + copyPosixFilePermissions(keyStorePath, tempPath); + + try (var outputStream = new FileOutputStream(tempPath.toFile())) { + keyStore.store(outputStream, settings.getKeyStorePassword.get()); + + // Force the contents to disk before the move, so a crash can't leave the moved-into-place + // file holding nothing. + outputStream.getFD().sync(); + } + + Files.move( + tempPath, + keyStorePath, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + try { + Files.deleteIfExists(tempPath); + } catch (IOException ex) { + e.addSuppressed(ex); + } + + throw e; + } + } + + /** + * Resolve the path to write the KeyStore to, following symbolic links so that an existing link is + * updated in place rather than replaced by a regular file. + * + * @return the absolute, link-resolved path of the KeyStore file. + * @throws IOException if an error occurs while resolving the path. + */ + private Path resolveKeyStorePath() throws IOException { + Path keyStorePath = settings.keyStorePath.toAbsolutePath(); + + return Files.exists(keyStorePath) ? keyStorePath.toRealPath() : keyStorePath; + } + + /** + * Copy the POSIX permissions of {@code from} onto {@code to}, if {@code from} exists and the file + * system supports them. + * + *
Temporary files are created readable only by their owner, so without this the KeyStore would + * lose any permissions the user had configured every time it was replaced. + * + * @param from the file to read permissions from. + * @param to the file to apply them to. + * @throws IOException if an error occurs while reading or applying the permissions. + */ + private static void copyPosixFilePermissions(Path from, Path to) throws IOException { + if (Files.exists(from) + && from.getFileSystem().supportedFileAttributeViews().contains("posix")) { + + Files.setPosixFilePermissions(to, Files.getPosixFilePermissions(from)); + } + } + + /** + * Restore {@code previousEntry} under {@code alias} after a failed write, so the in-memory + * KeyStore does not diverge from the file on disk. + * + *
The {@code entries} cache needs no equivalent treatment: {@link #get(NodeId)} falls back to
+ * the KeyStore and repopulates it.
+ *
+ * @param alias the alias to restore.
+ * @param previousEntry the entry that was present before the write, or {@code null} if there was
+ * none.
+ * @param password the password protecting {@code alias}.
+ * @param cause the failure being recovered from, which any failure to restore is attached to.
+ */
+ private void restoreEntry(
+ String alias, KeyStore.@Nullable Entry previousEntry, char[] password, Exception cause) {
+
+ try {
+ if (previousEntry != null) {
+ keyStore.setEntry(alias, previousEntry, new KeyStore.PasswordProtection(password));
+ } else {
+ keyStore.deleteEntry(alias);
+ }
+ } catch (KeyStoreException e) {
+ cause.addSuppressed(e);
+ }
+ }
+
private void configureWatchService(File keyStoreFile) throws IOException {
watchService = FileSystems.getDefault().newWatchService();
diff --git a/opc-ua-stack/stack-core/src/test/java/org/eclipse/milo/opcua/stack/core/security/KeyStoreCertificateStoreTest.java b/opc-ua-stack/stack-core/src/test/java/org/eclipse/milo/opcua/stack/core/security/KeyStoreCertificateStoreTest.java
index 70cff30f06..ba79778941 100644
--- a/opc-ua-stack/stack-core/src/test/java/org/eclipse/milo/opcua/stack/core/security/KeyStoreCertificateStoreTest.java
+++ b/opc-ua-stack/stack-core/src/test/java/org/eclipse/milo/opcua/stack/core/security/KeyStoreCertificateStoreTest.java
@@ -10,12 +10,24 @@
package org.eclipse.milo.opcua.stack.core.security;
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.security.KeyPair;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
class KeyStoreCertificateStoreTest extends CertificateStoreTest {
@@ -27,8 +39,8 @@ class KeyStoreCertificateStoreTest extends CertificateStoreTest {
@AfterEach
void deleteTestFiles() {
try {
- Files.deleteIfExists(testPath);
Files.deleteIfExists(keyStorePath);
+ Files.deleteIfExists(testPath);
} catch (Exception ignored) {
testPath.toFile().deleteOnExit();
keyStorePath.toFile().deleteOnExit();
@@ -37,19 +49,79 @@ void deleteTestFiles() {
@Override
protected CertificateStore newCertificateStore() throws Exception {
- var store =
- new KeyStoreCertificateStore(
- new KeyStoreCertificateStore.Settings(
- keyStorePath, "password"::toCharArray, alias -> "password".toCharArray())) {
-
- @Override
- protected @Nullable String getAlias(NodeId certificateTypeId) {
- return certificateTypeId.getIdentifier().toString();
- }
- };
+ KeyStoreCertificateStore store = newKeyStoreCertificateStore("password"::toCharArray);
store.initialize();
return store;
}
+
+ /**
+ * Opening a second store over the same file is the only way to tell that {@link
+ * CertificateStore#set(NodeId, CertificateStore.Entry)} actually wrote anything; the assertions
+ * inherited from {@link CertificateStoreTest} all pass against the in-memory KeyStore alone.
+ */
+ @Test
+ void entriesAreWrittenToDisk() throws Exception {
+ var certificateTypeId = new NodeId(2, "persisted");
+
+ certificateStore.set(certificateTypeId, newEntry());
+
+ assertNotNull(newCertificateStore().get(certificateTypeId));
+ }
+
+ @Test
+ void failedWriteLeavesKeyStoreIntact() throws Exception {
+ var certificateTypeId = new NodeId(2, "unwritable");
+ var failWrite = new AtomicBoolean(false);
+
+ KeyStoreCertificateStore store =
+ newKeyStoreCertificateStore(
+ () -> {
+ if (failWrite.get()) {
+ throw new IllegalStateException("KeyStore password unavailable");
+ }
+ return "password".toCharArray();
+ });
+
+ store.initialize();
+ store.set(new NodeId(2, "survivor"), newEntry());
+
+ failWrite.set(true);
+ assertThrows(IllegalStateException.class, () -> store.set(certificateTypeId, newEntry()));
+ failWrite.set(false);
+
+ // The failed write must be rolled back in memory...
+ assertFalse(store.contains(certificateTypeId));
+
+ // ...must not have left a partially written temporary file behind...
+ try (Stream