diff --git a/README.md b/README.md index bfaf37a..b415433 100644 --- a/README.md +++ b/README.md @@ -77,10 +77,9 @@ ## About The Project -Extending access to Keeper secrets manager for api retrival in -distributed or disconnected processes. Serves as a quality of life -abstraction to diminish the scourge of hard-coded, insecurely -handled credentials in our code bases. +Extending access to Keeper secrets manager for api retrival in distributed or +disconnected processes. Serves as a quality of life abstraction to diminish the +scourge of hard-coded, insecurely handled credentials in our code bases.

(back to top)

@@ -99,14 +98,14 @@ _Java is like a bad relationship. It's too object-oriented_ ## Getting Started -Compiling is not necessary as release binaries are available. If -you're so inclined the sections below are for you. +Compiling is not necessary as release binaries are available. If you're so +inclined the sections below are for you. ### Prerequisites -Your going to need a compiler, I recommend anything not Oracle java. -Depending on your os, the installation process will vary. Additional -packages like maven will be needed to utilize the provided pom file. +Your going to need a compiler, I recommend anything not Oracle java. Depending +on your os, the installation process will vary. Additional packages like maven +will be needed to utilize the provided pom file. #### CentOS * bash @@ -188,8 +187,11 @@ packages like maven will be needed to utilize the provided pom file. ### Configuration Every knob lives in `credcat.properties`. All are optional and fall back to sane -defaults, so an empty file is a working file. The `server.*` settings are ignored -in stand-alone mode. +defaults, so an empty file is a working file. The `server.*` settings are +ignored in stand-alone mode. This file can be placed in the current working +directory of the jar file to avoid needing to recompile on change. That location +can be overriden by setting the `CREDCAT_CONFIG_FILE` environment variable or +the `-Dcredcat.config.file` flag on init. ```properties # Keeper @@ -202,18 +204,31 @@ in stand-alone mode. # Files file.clean=true # wipe the files directory recursively on shutdown - file.transport=inline # disk | inline | none + file.transport=inline # record attachments: disk | inline | none # Server - server.host=127.0.0.1 - server.port=8888 + server.host=127.0.0.1 # hostname or IP for server service to run on + server.port=8888 # port to bind service on. Prefer >= 1024 server.max_request_bytes=1048576 # larger request bodies are rejected with a 413 server.threads= # worker pool size (defaults to max(8, 2x cpu cores)) + + # TLS + server.tls.client_auth=NONE # mutual tls: need | none | want + server.tls.enabled=false # serve https instead of http + server.tls.keystore= # path to keystore holding server certificate + server.tls.keystore_password= # literal credential to unlock keystore + server.tls.keystore_password_env= # env var name holding keystore credential + server.tls.keystore_type=PKCS12 # BKS | JKS | KeychainStore | PKCS12 + server.tls.protocols= # comma seperated protocol versions to use + server.tls.truststore= # path to truststore holding client CAs + server.tls.truststore_password= # literal credential to unlock truststore + server.tls.truststore_password_env= # env var name holding truststore credential + server.tls.truststore_type=PKCS12 # BKS | JKS | KeychainStore | PKCS12 ``` -A named lookup (`configName`) is resolved against `keeper.config.dir` first, then -the `keeper.config.env` prefix; the literal `config` parameter always wins when both -are present, and the `keeper.config` default backs them all. +A named lookup `configName` is resolved against `keeper.config.dir` first, then +the `keeper.config.env` prefix. The literal `config` parameter always wins when both +are present and the `keeper.config` default backs them all. @@ -229,15 +244,16 @@ parameter to switch between pre-defined choices stashed in either a directory or through environment variables. The `config`, `configName` and `clientKey` parameters are your means to alternate between application vaults. -Pass one or more of either titles and/or record uid's to retrieve multiple records -at once. Exact matches only. +Pass one or more of either titles and/or record uid's to retrieve multiple +records at once. Exact matches only. -Attached files are handed back however your deployment prefers, set globally with -the `file.transport` property or overridden per-request with `fileTransport`: +Attached files are handed back however your deployment prefers, set globally +with the `file.transport` property or overridden per-request with +`fileTransport`: * `disk` written to the save location, whose path is returned in the response. -* `inline` base64 encoded straight into the response; nothing touches the disk. -* `none` skipped entirely; only the file's metadata comes back. +* `inline` base64 encoded straight into the response, nothing touches the disk. +* `none` skipped entirely, only the file's metadata comes back. ```sh Usage: java -jar credcat.jar [ -server | '{ "config": ".keeper/config.base64", "titles": ["RECORD_TITLE"], "uids": ["RECORD_UID"] }' ] @@ -254,60 +270,67 @@ the `file.transport` property or overridden per-request with `fileTransport`: 2. Whether passing title or uid, records are returned nested under its respective uid. Using the `disk` transport: ```sh - java -cp "target/classes:target/dependency/*" com.byteskeptical.credcat.SecretsService "$ADVANCED" + java -cp "target/classes:target/lib/*" com.byteskeptical.credcat.SecretsService "$ADVANCED" java -jar target/credcat.jar "$UID_ONLY" ``` ```json - INFO: { - "7bN_ceW-p3_alVUNmI09Tw" : { - "fields" : { - "password" : [ "bingbangboomdongle" ], - "login" : [ "ldaptest" ] - }, - "files" : [ ], - "title" : "development ldap", - "type" : "login" + "7bN_ceW-p3_alVUNmI09Tw" : { + "fields" : { + "password" : [ "bingbangboomdongle" ], + "login" : [ "ldaptest" ] + }, + "files" : [ ], + "title" : "development ldap", + "type" : "login" + }, + "chnmGhEC39YCHhNy1pA8vg" : { + "fields" : { + "password" : [ "be0d988f-063c-d654-ad1b-a54337f87233" ], + "login" : [ "integration.ucaas.call.metadata" ], + "fileref" : [ "3HcX3vCCvHBTBcOqCgCnsQ", "cGBiPmG_9GlZszFbsQmJea" ] }, - "chnmGhEC39YCHhNy1pA8vg" : { - "fields" : { - "password" : [ "be0d988f-063c-d654-ad1b-a54337f87233" ], - "login" : [ "integration.ucaas.call.metadata" ], - "fileref" : [ "3HcX3vCCvHBTBcOqCgCnsQ", "cGBiPmG_9GlZszFbsQmJea" ] - }, - "files" : [ { - "name" : "ascii-art.txt", - "path" : "/tmp/credcat_8f3a1c20-5e7b-4a9d-bd11-2c6f0e9a4477/ascii-art.txt", - "mimeType" : "text/plain", - "size" : 318 - }, { - "name" : "integration.ucaas.call.metadata.PNG", - "path" : "/tmp/credcat_8f3a1c20-5e7b-4a9d-bd11-2c6f0e9a4477/integration.ucaas.call.metadata.PNG", - "mimeType" : "image/png", - "size" : 20480 - } ], - "notes" : "VALUE = x-ClickToCall-APIKey:be0d988f-063c-d654-ad1b-a54337f87233", - "title" : "Production ClickToCall API Key", - "type" : "login" - } - } + "files" : [ { + "name" : "ascii-art.txt", + "path" : "/tmp/credcat_8f3a1c20-5e7b-4a9d-bd11-2c6f0e9a4477/ascii-art.txt", + "mimeType" : "text/plain", + "size" : 318 + }, { + "name" : "integration.ucaas.call.metadata.PNG", + "path" : "/tmp/credcat_8f3a1c20-5e7b-4a9d-bd11-2c6f0e9a4477/integration.ucaas.call.metadata.PNG", + "mimeType" : "image/png", + "size" : 20480 + } ], + "notes" : "VALUE = x-ClickToCall-APIKey:be0d988f-063c-d654-ad1b-a54337f87233", + "title" : "Production ClickToCall API Key", + "type" : "login" } ``` The default `inline` transport trades a file `path` for base64 `content`, leaving nothing on the host: ```json - "files" : [ { - "content" : "ICAgIC9cX18vXAogICAoIC1fLSApCiAgIC8gPiA+IFwK", - "mimeType" : "text/plain", - "name" : "ascii-art.txt", - "size" : 318 - } ] + "chnmGhEC39YCHhNy1pA8vg" : { + "fields" : { + "password" : [ "be0d988f-063c-d654-ad1b-a54337f87233" ], + "login" : [ "integration.ucaas.call.metadata" ], + "fileref" : [ "3HcX3vCCvHBTBcOqCgCnsQ" ] + }, + "files" : [ { + "content" : "ICAgIC9cX18vXAogICAoIC1fLSApCiAgIC8gPiA+IFwK", + "mimeType" : "text/plain", + "name" : "ascii-art.txt", + "size" : 318 + } ], + "notes" : "VALUE = x-ClickToCall-APIKey:be0d988f-063c-d654-ad1b-a54337f87233", + "title" : "Production ClickToCall API Key", + "type" : "login" + } ``` 3. Running in server mode accepts the same request payload, passed by the http client of your choice. You can set your preferred host and port in the credcat properties file. ```sh - java -cp "target/classes:target/dependency/*" -server + java -cp "target/classes:target/lib/*" -server java -jar target/credcat.jar -server ``` ```sh diff --git a/pom.xml b/pom.xml index 301e606..2ce0532 100644 --- a/pom.xml +++ b/pom.xml @@ -65,7 +65,7 @@ false - ${project.build.directory}/dependency + ${project.build.directory}/lib false @@ -103,7 +103,7 @@ true - dependency/ + lib/ ${project.groupId}.credcat.SecretsService diff --git a/src/main/java/com/byteskeptical/credcat/SecretsService.java b/src/main/java/com/byteskeptical/credcat/SecretsService.java index 198d2a6..055ba2c 100644 --- a/src/main/java/com/byteskeptical/credcat/SecretsService.java +++ b/src/main/java/com/byteskeptical/credcat/SecretsService.java @@ -1,12 +1,14 @@ package com.byteskeptical.credcat; import com.byteskeptical.credcat.config.KeeperConfig; +import com.byteskeptical.credcat.config.TlsConfig; import com.byteskeptical.credcat.file.FileHandler; import com.byteskeptical.credcat.file.FileTransport; import com.byteskeptical.credcat.model.KeeperRequest; import com.byteskeptical.credcat.model.SecretResponse; import com.byteskeptical.credcat.util.Checks; import com.byteskeptical.credcat.util.JsonHandler; +import com.fasterxml.jackson.core.JsonProcessingException; import com.keepersecurity.secretsManager.core.AccountNumber; import com.keepersecurity.secretsManager.core.AddressRef; import com.keepersecurity.secretsManager.core.BankAccounts; @@ -48,9 +50,11 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsServer; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -116,18 +120,6 @@ public SecretsService(AppConfig appConfig) { this.appConfig = appConfig; } - /** - * If you don't know, now you know. - * - * @return A string representing the service version. - */ - public String getVersion() { - Package pkg = SecretsService.class.getPackage(); - String version = pkg != null ? pkg.getImplementationVersion() : null; - - return version != null ? version : VERSION; - } - /** * Load properties, provide sane defaults. */ @@ -142,6 +134,7 @@ static class AppConfig { final Path filesDir; final String clientKey; final String host; + final TlsConfig tls; /** * Properties from classpath or filesystem, prioritizing filesystem. @@ -164,8 +157,13 @@ static class AppConfig { } // Load from Filesystem - File fsConfig = new File(CONFIG); - if (fsConfig.exists() && fsConfig.isFile()) { + String override = Checks.trimToNull(System.getProperty("credcat.config.file")); + if (override == null) { + override = Checks.trimToNull(System.getenv("CREDCAT_CONFIG_FILE")); + } + + File fsConfig = new File(override != null ? override : CONFIG); + if (fsConfig.isFile()) { try (FileInputStream fis = new FileInputStream(fsConfig)) { props.load(fis); LOGGER.log(Level.INFO, @@ -174,11 +172,16 @@ static class AppConfig { ); } catch (IOException e) { LOGGER.log(Level.SEVERE, - "Found the config " + CONFIG + "Found the config " + fsConfig.getAbsolutePath() + " but failed to read it. Check the permissions.", e ); throw e; } + } else if (override != null) { + throw new FileNotFoundException( + "credcat.config.file points at " + fsConfig.getAbsolutePath() + + " but no readable file was found there." + ); } this.autoClean = Boolean.parseBoolean( @@ -192,21 +195,19 @@ static class AppConfig { this.port = parseInt(props.getProperty("server.port"), 8888); this.threads = parseInt( props.getProperty("server.threads"), THREADS); + this.tls = TlsConfig.fromProperties(props); String filesProp = Checks.trimToNull(props.getProperty("keeper.files")); - if (filesProp != null) { - this.filesDir = Path.of(filesProp); - } else { - String osTemp = System.getProperty("java.io.tmpdir"); - this.filesDir = Path.of(osTemp, "credcat_" + UUID.randomUUID()); - } + Path files = filesProp != null + ? Path.of(filesProp) + : Path.of(System.getProperty("java.io.tmpdir"), + "credcat_" + UUID.randomUUID()); + + this.filesDir = files.toAbsolutePath().normalize(); - // Storage mode default is in-memory. LocalConfigStorage when - // token refresh persistence is desired on a writable filesystem. this.persistentStorage = Boolean.parseBoolean( props.getProperty("keeper.storage.persistent", "false")); - // Default keeper.config, can be a file path or literal content. String configProp = Checks.trimToNull(props.getProperty("keeper.config")); String keeperConfig = null; @@ -218,7 +219,6 @@ static class AppConfig { } } - // Named-config sources. Filesystem dir, env vars. String dirProp = Checks.trimToNull(props.getProperty("keeper.config.dir")); Path dirPath = dirProp != null ? Path.of(dirProp) : null; @@ -248,9 +248,112 @@ private static int parseInt(String s, int fallback) { } } + /** + * Picks the Keeper storage method. In-memory by default, file-backed when + * persistent storage is enabled and the config came from a writable file. + */ + KeyValueStorage appStorage(KeeperConfig.KSM ksm) { + if (appConfig.persistentStorage && !Checks.isNullOrBlank(ksm.getOrigin())) { + Path path; + try { + path = Path.of(ksm.getOrigin()); + } catch (InvalidPathException e) { + path = null; + } + + if (path != null && Files.isRegularFile(path) && Files.isWritable(path)) { + LOGGER.log(Level.FINE, "Using LocalConfigStorage at {0}", path); + return new LocalConfigStorage(path.toString()); + } + } + + LOGGER.log(Level.FINE, "Using InMemoryStorage for Keeper config."); + return new InMemoryStorage(ksm.getContent()); + } + + /** + * Adds the BouncyCastle FIPS provider if it's on the classpath. Some + * platforms ship without it, in that case we fall back to the platform + * defaults and log a warning instead of dying. + */ + private static void bouncyCastle() { + try { + Class providerClass = Class.forName( + "org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider"); + java.security.Provider provider = + (java.security.Provider) providerClass + .getDeclaredConstructor().newInstance(); + Security.addProvider(provider); + } catch (ClassNotFoundException e) { + LOGGER.log(Level.WARNING, + "BouncyCastle FIPS provider not on classpath; " + + "using platform defaults."); + } catch (Exception e) { + LOGGER.log(Level.WARNING, + "Failed to install BouncyCastle FIPS provider; " + + "using platform defaults.", e); + } + } + + /** + * Recursively wipes the files directory at shutdown so we don't litter + * the host with credcat_* dirs. Gated by {@code file.clean}. + */ + private static void cleanFilesDir(Path dir) { + if (dir == null || !Files.exists(dir)) { + return; + } + + try (Stream walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + LOGGER.log(Level.FINE, + "Failed to delete temp entry " + p, e); + } + }); + } catch (IOException e) { + LOGGER.log(Level.FINE, + "Failed to walk temp directory " + dir, e); + } + } + + /** + * Resolves the directory DISK transport writes into. Request supplied + * locations are taken relative to {@code keeper.files} and may not + * escape it. Absent a request value, {@code keeper.files} itself is used. + * + * @param requested The request supplied saveLocation; may be {@code null}. + * @return The directory to hand to the file handler. + * @throws IllegalArgumentException if {@code requested} is not a valid + * path or escapes {@code keeper.files}. + */ + String filesLocation(String requested) { + String trimmed = Checks.trimToNull(requested); + + if (trimmed == null) { + return appConfig.filesDir.toString(); + } + + try { + Path resolved = appConfig.filesDir.resolve(trimmed).normalize(); + + if (resolved.startsWith(appConfig.filesDir)) { + return resolved.toString(); + } + } catch (InvalidPathException ignored) { + // rejected below + } + + throw new IllegalArgumentException( + "saveLocation must resolve inside the keeper.files directory."); + } + /** * Find KeeperRecords by UIDs and/or titles. - * Uses the SDK's UID filter when only UIDs are supplied; otherwise fetches + * Uses the SDK's UID filter when only UIDs are supplied. Otherwise fetches * once and filters in-process. Records are deduplicated by UID to handle * overlap between the two lookups. * @@ -315,6 +418,17 @@ List findRecords( return new ArrayList<>(byUid.values()); } + /** + * Formats epoch-millisecond timestamps as ISO-8601 UTC. Used identically + * by {@code BirthDate}, {@code Date} and {@code ExpirationDate} + * + * @param timestamps A list of epoch-millisecond values. Can be {@code null}. + * @return ISO-8601 string forms. + */ + private static List formatTimestamp(List timestamps) { + return mapValues(timestamps, ts -> Instant.ofEpochMilli(ts).toString()); + } + /** * Initialize secure storage then retrieve secrets. * @@ -346,14 +460,11 @@ public String getSecrets(String jsonRequest) throws Exception { FileTransport transport = FileTransport.parse( request.getFileTransport(), appConfig.fileTransport); - String requestLocation = request.getSaveLocation(); - String saveLocation = !Checks.isNullOrBlank(requestLocation) - ? requestLocation - : appConfig.filesDir.toString(); + String saveLocation = filesLocation(request.getSaveLocation()); KeyValueStorage storage; try { - storage = buildStorage(ksm); + storage = appStorage(ksm); } catch (Exception e) { String errorMessage = "Loading of KSM vault config failed. " + "Ensure the config is valid base64-encoded or JSON."; @@ -383,32 +494,92 @@ public String getSecrets(String jsonRequest) throws Exception { } /** - * Picks the Keeper storage impl. In-memory by default; file-backed when - * persistent storage is enabled and the config came from a writable file. + * If you don't know, now you know. + * + * @return A string representing the service version. */ - KeyValueStorage buildStorage(KeeperConfig.KSM ksm) { - if (appConfig.persistentStorage && !Checks.isNullOrBlank(ksm.getOrigin())) { - Path path; - try { - path = Path.of(ksm.getOrigin()); - } catch (InvalidPathException e) { - path = null; + public String getVersion() { + Package pkg = SecretsService.class.getPackage(); + String version = pkg != null ? pkg.getImplementationVersion() : null; + + return version != null ? version : VERSION; + } + + /** + * Returns true if at least one value in the list is non-empty. + */ + private static boolean hasValue(List values) { + if (Checks.isNullOrEmpty(values)) { + return false; + } + + for (String v : values) { + if (!Checks.isNullOrBlank(v)) { + return true; } + } - if (path != null && Files.isRegularFile(path) && Files.isWritable(path)) { - LOGGER.log(Level.FINE, "Using LocalConfigStorage at {0}", path); - return new LocalConfigStorage(path.toString()); + return false; + } + + /** + * Logging bootstrap. JUL only auto-loads from a system property; + * we can't always set one, so we look in the classpath and then + * alongside the JAR. Honors the user's + * {@code -Djava.util.logging.config.file} when set. + */ + private static void initLogging() { + if (System.getProperty("java.util.logging.config.file") != null) { + return; + } + + // Filesystem first. + File fs = new File(LOGGING); + if (fs.isFile()) { + try (FileInputStream fis = new FileInputStream(fs)) { + LogManager.getLogManager().readConfiguration(fis); + return; + } catch (IOException e) { + // fall through to classpath } } - LOGGER.log(Level.FINE, "Using InMemoryStorage for Keeper config."); - return new InMemoryStorage(ksm.getContent()); + // Classpath fallback. + try (InputStream is = SecretsService.class.getClassLoader() + .getResourceAsStream(LOGGING)) { + if (is != null) { + LogManager.getLogManager().readConfiguration(is); + } + } catch (IOException ignored) { + // Stick with JVM defaults. + } + } + + /** + * Maps a list of structured values to their string form, tolerating a null + * input list and skipping null entries. + * + * @param values The source list (may be {@code null}). + * @param fn Converter from a non-null value to its string form. + * @return A list of string forms, possibly empty, never {@code null}. + */ + private static List mapValues( + List values, Function fn + ) { + if (Checks.isNullOrEmpty(values)) { + return Collections.emptyList(); + } + + return values.stream() + .filter(Objects::nonNull) + .map(fn) + .collect(Collectors.toList()); } /** * Downloads files attached to a KeeperRecord, provides its metadata. * - * @param files A list of KeeperFile entries usually from a KeeperRecord. + * @param files A list of KeeperFile entries usually from a KeeperRecord. * @param handler The strategy to use for materializing each file. * @return A list of name, path file object details for downloaded files. */ @@ -443,7 +614,7 @@ List processFiles( /** * Process record(s) field(s) values, organize in a structured format. * - * @param records A list of KeeperRecord entries. + * @param records A list of KeeperRecord entries. * @param fileHandler The file handler chosen for this request. * @return A hashmap of credential fields and their values. */ @@ -501,50 +672,47 @@ Map> processRecords( } /** - * Returns true if at least one value in the list is non-empty. + * Reads a bounded amount of bytes from the request body. Returns + * {@code null} when the body exceeds the configured maximum, in which + * case the handler should reply 413. */ - private static boolean hasValue(List values) { - if (Checks.isNullOrEmpty(values)) { - return false; - } + private static byte[] readRequest(InputStream in, int maxBytes) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream( + Math.min(maxBytes, 8192)); + byte[] chunk = new byte[8192]; + int read; + int total = 0; - for (String v : values) { - if (!Checks.isNullOrBlank(v)) { - return true; - } - } + while ((read = in.read(chunk)) != -1) { + total += read; - return false; - } + if (total > maxBytes) { + return null; + } - /** - * Maps a list of structured values to their string form, tolerating a null - * input list and skipping null entries. - * - * @param values The source list (may be {@code null}). - * @param fn Converter from a non-null value to its string form. - * @return A list of string forms, possibly empty, never {@code null}. - */ - private static List mapValues(List values, Function fn) { - if (Checks.isNullOrEmpty(values)) { - return Collections.emptyList(); + buf.write(chunk, 0, read); } - return values.stream() - .filter(Objects::nonNull) - .map(fn) - .collect(Collectors.toList()); + return buf.toByteArray(); } /** - * Formats epoch-millis timestamps as ISO-8601 UTC. Used identically by - * {@code BirthDate}, {@code Date}, and {@code ExpirationDate} + * Don't shoot the messenger. * - * @param timestamps A list of epoch-millisecond values; may be {@code null}. - * @return ISO-8601 string forms. + * @param exchange Encapsulation of methods for request received and response. + * @param statusCode HTTP status of choice sent as response. + * @param response What say you back? */ - private static List formatTimestamps(List timestamps) { - return mapValues(timestamps, ts -> Instant.ofEpochMilli(ts).toString()); + private static void sendResponse( + HttpExchange exchange, int statusCode, String response + ) throws IOException { + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } } /** @@ -566,15 +734,15 @@ List xtraxField(KeeperRecordField field) { ba.getAccountType(), ba.getRoutingNumber(), ba.getAccountNumber(), ba.getOtherType())); } else if (field instanceof BirthDate) { - return formatTimestamps(((BirthDate) field).getValue()); + return formatTimestamp(((BirthDate) field).getValue()); } else if (field instanceof CardRef) { return ((CardRef) field).getValue(); } else if (field instanceof Date) { - return formatTimestamps(((Date) field).getValue()); + return formatTimestamp(((Date) field).getValue()); } else if (field instanceof Email) { return ((Email) field).getValue(); } else if (field instanceof ExpirationDate) { - return formatTimestamps(((ExpirationDate) field).getValue()); + return formatTimestamp(((ExpirationDate) field).getValue()); } else if (field instanceof FileRef) { return ((FileRef) field).getValue(); } else if (field instanceof HiddenField) { @@ -658,50 +826,6 @@ List xtraxField(KeeperRecordField field) { } } - /** - * Don't shoot the messenger. - * - * @param exchange Encapsulation of methods for request received and response. - * @param statusCode HTTP status of choice sent as response. - * @param response What say you back? - */ - private static void sendResponse( - HttpExchange exchange, int statusCode, String response - ) throws IOException { - byte[] bytes = response.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().set("Content-Type", "application/json"); - exchange.sendResponseHeaders(statusCode, bytes.length); - - try (OutputStream os = exchange.getResponseBody()) { - os.write(bytes); - } - } - - /** - * Reads a bounded amount of bytes from the request body. Returns - * {@code null} when the body exceeds the configured maximum, in which - * case the handler should reply 413. - */ - private static byte[] readRequest(InputStream in, int maxBytes) throws IOException { - ByteArrayOutputStream buf = new ByteArrayOutputStream( - Math.min(maxBytes, 8192)); - byte[] chunk = new byte[8192]; - int read; - int total = 0; - - while ((read = in.read(chunk)) != -1) { - total += read; - - if (total > maxBytes) { - return null; - } - - buf.write(chunk, 0, read); - } - - return buf.toByteArray(); - } - /** * Handles POST requests for secrets. */ @@ -739,6 +863,10 @@ public void handle(HttpExchange exchange) throws IOException { LOGGER.warning("Bad Request: " + e.getMessage()); sendResponse(exchange, 400, JsonHandler.envelope("error", e.getMessage())); + } catch (JsonProcessingException e) { + LOGGER.warning("Malformed JSON: " + e.getOriginalMessage()); + sendResponse(exchange, 400, JsonHandler.envelope("error", + "Request body is not valid JSON.")); } catch (Exception e) { LOGGER.log(Level.SEVERE, "Internal Error", e); String detail = e.getMessage() != null @@ -773,88 +901,6 @@ public void handle(HttpExchange exchange) throws IOException { } } - /** - * Recursively wipes the files directory at shutdown so we don't litter - * the host with credcat_* dirs. Gated solely by {@code file.clean}. - */ - private static void cleanFilesDir(Path dir) { - if (dir == null || !Files.exists(dir)) { - return; - } - - try (Stream walk = Files.walk(dir)) { - walk.sorted(Comparator.reverseOrder()) - .forEach(p -> { - try { - Files.deleteIfExists(p); - } catch (IOException e) { - LOGGER.log(Level.FINE, - "Failed to delete temp entry " + p, e); - } - }); - } catch (IOException e) { - LOGGER.log(Level.FINE, - "Failed to walk temp directory " + dir, e); - } - } - - /** - * Logging bootstrap. JUL only auto-loads from a system property; - * we can't always set one, so we look in the classpath and then - * alongside the JAR. Honors the user's - * {@code -Djava.util.logging.config.file} when set. - */ - private static void initLogging() { - if (System.getProperty("java.util.logging.config.file") != null) { - return; - } - - // Filesystem first. - File fs = new File(LOGGING); - if (fs.isFile()) { - try (FileInputStream fis = new FileInputStream(fs)) { - LogManager.getLogManager().readConfiguration(fis); - return; - } catch (IOException e) { - // fall through to classpath - } - } - - // Classpath fallback. - try (InputStream is = SecretsService.class.getClassLoader() - .getResourceAsStream(LOGGING)) { - if (is != null) { - LogManager.getLogManager().readConfiguration(is); - } - } catch (IOException ignored) { - // Stick with JVM defaults. - } - } - - /** - * Adds the BouncyCastle FIPS provider if it's on the classpath. Some - * platforms ship without it; in that case we fall back to the platform - * defaults and log a warning instead of dying. - */ - private static void bouncyCastle() { - try { - Class providerClass = Class.forName( - "org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider"); - java.security.Provider provider = - (java.security.Provider) providerClass - .getDeclaredConstructor().newInstance(); - Security.addProvider(provider); - } catch (ClassNotFoundException e) { - LOGGER.log(Level.WARNING, - "BouncyCastle FIPS provider not on classpath; " - + "using platform defaults."); - } catch (Exception e) { - LOGGER.log(Level.WARNING, - "Failed to install BouncyCastle FIPS provider; " - + "using platform defaults.", e); - } - } - /** * Main method. Mode based on arguments. * @@ -885,9 +931,17 @@ public static void main(String[] args) { SecretsService service = new SecretsService(config); if (args[0].equals("-server")) { - HttpServer server = HttpServer.create( - new InetSocketAddress(config.host, config.port), 0 - ); + InetSocketAddress address = + new InetSocketAddress(config.host, config.port); + final HttpServer server; + + if (config.tls.isEnabled()) { + HttpsServer https = HttpsServer.create(address, 0); + https.setHttpsConfigurator(config.tls.createConfigurator()); + server = https; + } else { + server = HttpServer.create(address, 0); + } server.createContext( "/api/getSecrets", @@ -930,8 +984,11 @@ public static void main(String[] args) { long elapsed = System.currentTimeMillis() - startTime; LOGGER.log(Level.INFO, - "Credcat started meowing in {0}ms on {1}:{2,number,#}", - new Object[] { elapsed, config.host, config.port }); + "Credcat started meowing in {0}ms on {1}://{2}:{3,number,#}", + new Object[] { + elapsed, + config.tls.isEnabled() ? "https" : "http", + config.host, config.port }); } else { try { String response = service.getSecrets(args[0]); diff --git a/src/main/java/com/byteskeptical/credcat/config/KeeperConfig.java b/src/main/java/com/byteskeptical/credcat/config/KeeperConfig.java index 524db6e..bd71b32 100644 --- a/src/main/java/com/byteskeptical/credcat/config/KeeperConfig.java +++ b/src/main/java/com/byteskeptical/credcat/config/KeeperConfig.java @@ -21,9 +21,9 @@ * or the service-wide default in that order. * *
    - *
  1. An explicit literal in the request body ({@code config}) -- treated as + *
  2. An explicit literal in the request body ({@code config}), treated as * either a filesystem path or raw base64/JSON content.
  3. - *
  4. A named config in the request body ({@code configName}) -- looked up + *
  5. A named config in the request body ({@code configName}), looked up * against the configured directory and environment variable prefix * (in that order).
  6. *
  7. The service default ({@code keeper.config} from properties).
  8. @@ -36,7 +36,9 @@ public class KeeperConfig { private static final Logger LOGGER = Logger.getLogger(KeeperConfig.class.getName()); /** Filename extensions tried during named lookup. */ - private static final List EXTENSIONS = List.of(".json", ".b64", ""); + private static final List EXTENSIONS = List.of( + ".json", ".b64", "base64", "" + ); private final String configContent; private final Path configDir; @@ -123,7 +125,8 @@ public KSM resolve(String config, String configName) { * * @param raw The value to interpret. * @return The resolved config. Never {@code null}. - * @throws IllegalArgumentException if {@code raw} is none of the three. + * @throws IllegalArgumentException if {@code raw} is none of the three + * or names an existing file that cannot be read. */ public KSM interpret(String raw) { Objects.requireNonNull(raw, "raw"); @@ -145,6 +148,9 @@ public KSM interpret(String raw) { path.toString()); } catch (IOException e) { LOGGER.log(Level.SEVERE, "Failed to read Keeper config file.", e); + throw new IllegalArgumentException( + "config points at " + path + " but reading it failed: " + + e.getMessage(), e); } } diff --git a/src/main/java/com/byteskeptical/credcat/config/TlsConfig.java b/src/main/java/com/byteskeptical/credcat/config/TlsConfig.java new file mode 100644 index 0000000..8a3b35a --- /dev/null +++ b/src/main/java/com/byteskeptical/credcat/config/TlsConfig.java @@ -0,0 +1,318 @@ +package com.byteskeptical.credcat.config; + +import com.byteskeptical.credcat.util.Checks; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Properties; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManagerFactory; + +/** + * Optional TLS for the embedded server, resolved from the + * {@code server.tls.*} properties. + * + *

    Disabled by default. When {@code server.tls.enabled=true} the server + * identity is read from a keystore ({@code server.tls.keystore}) and the + * keystore password is resolved in two steps:

    + * + *
      + *
    1. {@code server.tls.keystore_password}: a literal in the properties + * file. Convenient for development; avoid in production.
    2. + *
    3. {@code server.tls.keystore_password_env}: the name of an + * environment variable holding the password. The recommended option.
    4. + *
    + * + *

    Mutual TLS is available via {@code server.tls.client_auth} (NONE, WANT, + * NEED) with an optional dedicated truststore; without one, the JVM's default + * trust anchors are used. Enabled protocols can be pinned with + * {@code server.tls.protocols}.

    + * + *

    Construction only parses; the keystore is opened by + * {@link #createConfigurator()} so a bad TLS setup fails loudly at server + * startup rather than on the first request. This class never logs key + * material or passwords.

    + */ +public final class TlsConfig { + + private static final Logger LOGGER = Logger.getLogger(TlsConfig.class.getName()); + + /** The instance handed out when TLS is disabled. */ + private static final TlsConfig DISABLED = new TlsConfig( + false, ClientAuth.NONE, null, null, null, List.of(), null, null, null); + + private final boolean enabled; + private final ClientAuth clientAuth; + private final char[] keystorePassword; + private final List protocols; + private final Path keystorePath; + private final Path truststorePath; + private final char[] truststorePassword; + private final String keystoreType; + private final String truststoreType; + + /** + * How the server treats client certificates during the handshake. + */ + public enum ClientAuth { + /** Never request a client certificate. The default. */ + NONE, + /** Request a certificate; proceed without one. */ + WANT, + /** Require a certificate; refuse the handshake without one. */ + NEED; + + /** + * Parser for the client auth configuration value. Ignores case, + * falls back to {@link #NONE} when the input is null, blank, or + * unrecognized. + * + * @param value The textual value to parse. May be {@code null}. + * @return A non-null ClientAuth. + */ + static ClientAuth parse(String value) { + String trimmed = Checks.trimToNull(value); + + if (trimmed == null) { + return NONE; + } + + try { + return ClientAuth.valueOf(trimmed.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + LOGGER.log(Level.WARNING, + "Unrecognized server.tls.client_auth ''{0}''; using NONE.", + trimmed); + return NONE; + } + } + } + + private TlsConfig(boolean enabled, ClientAuth clientAuth, + Path keystorePath, String keystoreType, char[] keystorePassword, + List protocols, + Path truststorePath, String truststoreType, + char[] truststorePassword) { + this.clientAuth = clientAuth; + this.enabled = enabled; + this.keystorePassword = keystorePassword; + this.keystorePath = keystorePath; + this.keystoreType = keystoreType; + this.protocols = protocols; + this.truststorePassword = truststorePassword; + this.truststorePath = truststorePath; + this.truststoreType = truststoreType; + } + + /** + * Builds a TlsConfig from the {@code server.tls.*} properties. + * + * @param props The loaded application properties. + * @return An enabled TlsConfig, or the disabled instance when + * {@code server.tls.enabled} is unset or false. + * @throws IllegalArgumentException if TLS is enabled but the keystore + * path or its password cannot be resolved. + */ + public static TlsConfig fromProperties(Properties props) { + boolean enabled = Boolean.parseBoolean( + props.getProperty("server.tls.enabled", "false")); + + if (!enabled) { + return DISABLED; + } + + String keystoreProp = Checks.trimToNull( + props.getProperty("server.tls.keystore")); + if (keystoreProp == null) { + throw new IllegalArgumentException( + "server.tls.enabled is true but server.tls.keystore is not set."); + } + + char[] keystorePassword = resolvePassword(props, + "server.tls.keystore_password", + "server.tls.keystore_password_env"); + if (keystorePassword == null) { + throw new IllegalArgumentException( + "server.tls.enabled is true but no keystore password was " + + "resolved. Set server.tls.keystore_password or point " + + "server.tls.keystore_password_env at an environment " + + "variable holding it."); + } + + String protocolsProp = Checks.trimToNull( + props.getProperty("server.tls.protocols")); + List protocols = protocolsProp == null + ? List.of() + : Arrays.stream(protocolsProp.split(",")) + .map(Checks::trimToNull) + .filter(Objects::nonNull) + .collect(Collectors.toUnmodifiableList()); + + String truststoreProp = Checks.trimToNull( + props.getProperty("server.tls.truststore")); + + return new TlsConfig( + true, + ClientAuth.parse(props.getProperty("server.tls.client_auth")), + Path.of(keystoreProp), + props.getProperty("server.tls.keystore_type", "PKCS12"), + keystorePassword, + protocols, + truststoreProp != null ? Path.of(truststoreProp) : null, + props.getProperty("server.tls.truststore_type", "PKCS12"), + resolvePassword(props, + "server.tls.truststore_password", + "server.tls.truststore_password_env")); + } + + /** + * Is TLS on for this deployment?. + * + * @return {@code true} when the server should listen for HTTPS. + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Opens the keystore (and truststore, when configured), builds the + * SSLContext, and wraps it in a configurator that applies the protocol + * pinning and client auth policy to every handshake. + * + * @return A configurator ready for {@code HttpsServer.setHttpsConfigurator}. + * @throws IOException if a store file cannot be read. + * @throws GeneralSecurityException if a store cannot be opened with the + * resolved password or the SSLContext cannot be initialized. + * @throws IllegalStateException if invoked while TLS is disabled. + */ + public HttpsConfigurator createConfigurator() + throws IOException, GeneralSecurityException { + if (!enabled) { + throw new IllegalStateException("TLS is not enabled."); + } + + KeyManagerFactory kmf = KeyManagerFactory.getInstance( + KeyManagerFactory.getDefaultAlgorithm()); + try { + KeyStore keystore = loadStore( + keystorePath, keystoreType, keystorePassword); + kmf.init(keystore, keystorePassword); + } finally { + Arrays.fill(keystorePassword, '\0'); + } + + TrustManagerFactory tmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + if (truststorePath != null) { + try { + tmf.init(loadStore( + truststorePath, truststoreType, truststorePassword)); + } finally { + if (truststorePassword != null) { + Arrays.fill(truststorePassword, '\0'); + } + } + } else { + // null keystore == the JVM's default trust anchors (cacerts). + tmf.init((KeyStore) null); + + if (clientAuth != ClientAuth.NONE) { + LOGGER.log(Level.WARNING, + "Client auth is {0} but no server.tls.truststore is " + + "set; client certificates will be validated against " + + "the JVM default trust anchors.", clientAuth); + } + } + + SSLContext context = SSLContext.getInstance("TLS"); + context.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + + LOGGER.log(Level.INFO, + "TLS enabled (keystore: {0}, client auth: {1}{2}).", + new Object[] { + keystorePath, + clientAuth, + protocols.isEmpty() ? "" : ", protocols: " + protocols }); + + return new HttpsConfigurator(context) { + @Override + public void configure(HttpsParameters params) { + SSLParameters ssl = getSSLContext().getDefaultSSLParameters(); + + if (!protocols.isEmpty()) { + ssl.setProtocols(protocols.toArray(new String[0])); + } + + switch (clientAuth) { + case NEED: + ssl.setNeedClientAuth(true); + break; + case WANT: + ssl.setWantClientAuth(true); + break; + case NONE: + default: + break; + } + + params.setSSLParameters(ssl); + } + }; + } + + /** + * Loads a keystore file with the given type and password. + */ + private static KeyStore loadStore(Path path, String type, char[] password) + throws IOException, GeneralSecurityException { + KeyStore store = KeyStore.getInstance(type); + + try (InputStream is = Files.newInputStream(path)) { + store.load(is, password); + } + + return store; + } + + /** + * Resolves a password from a literal property first, then from the + * environment variable named by the companion {@code *_env} property. + * + * @return The password, or {@code null} when neither source yields one. + */ + private static char[] resolvePassword( + Properties props, String literalKey, String envKey) { + String literal = Checks.trimToNull(props.getProperty(literalKey)); + if (literal != null) { + return literal.toCharArray(); + } + + String envName = Checks.trimToNull(props.getProperty(envKey)); + if (envName != null) { + String value = Checks.trimToNull(System.getenv(envName)); + if (value != null) { + return value.toCharArray(); + } + + LOGGER.log(Level.WARNING, + "{0} points at ''{1}'' but that variable is unset or blank.", + new Object[] { envKey, envName }); + } + + return null; + } +} diff --git a/src/main/java/com/byteskeptical/credcat/file/FileHandler.java b/src/main/java/com/byteskeptical/credcat/file/FileHandler.java index de19ed8..76d58a9 100644 --- a/src/main/java/com/byteskeptical/credcat/file/FileHandler.java +++ b/src/main/java/com/byteskeptical/credcat/file/FileHandler.java @@ -12,6 +12,7 @@ import java.nio.file.Path; import java.util.Base64; import java.util.Objects; +import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; @@ -120,6 +121,11 @@ public SecretResponse.FileInfo handle(KeeperFile file) throws IOException { Metadata m = metadataFor(file); Path target = downloadDir.resolve(sanitize(m.name)); + if (Files.exists(target)) { + target = downloadDir.resolve( + UUID.randomUUID() + "_" + target.getFileName()); + } + try (OutputStream os = new BufferedOutputStream( Files.newOutputStream(target), BUFFER_SIZE)) { os.write(bytes); @@ -133,12 +139,20 @@ public SecretResponse.FileInfo handle(KeeperFile file) throws IOException { m.name, target.toString(), m.mime, (long) bytes.length); } + /** + * Keeps an attachment name inside the download directory: separators + * become underscores, and the bare dot names ({@code "."}, + * {@code ".."}) that would resolve to the directory itself or its + * parent fall back to {@code "unnamed"}. + */ private static String sanitize(String name) { if (Checks.isNullOrBlank(name)) { return "unnamed"; } - return name.replace('/', '_').replace('\\', '_'); + String safe = name.replace('/', '_').replace('\\', '_'); + + return ".".equals(safe) || "..".equals(safe) ? "unnamed" : safe; } } diff --git a/src/main/java/com/byteskeptical/credcat/model/KeeperRequest.java b/src/main/java/com/byteskeptical/credcat/model/KeeperRequest.java index 7768add..56f5b88 100644 --- a/src/main/java/com/byteskeptical/credcat/model/KeeperRequest.java +++ b/src/main/java/com/byteskeptical/credcat/model/KeeperRequest.java @@ -98,7 +98,7 @@ public String getSaveLocation() { /** * Sets the save location for downloaded files. * - * @param saveLocation The location to save downloaded files too. + * @param saveLocation The location to save downloaded files to. */ public void setSaveLocation(String saveLocation) { this.saveLocation = saveLocation; @@ -107,14 +107,14 @@ public void setSaveLocation(String saveLocation) { /** * Gets the list of titles to search for. * - * @return The list of titles cooresponding to records for lookup. + * @return The list of titles corresponding to records for lookup. */ public List getTitles() { return titles; } /** - * Sets the list of titles to preform a record search on. + * Sets the list of titles to perform a record search on. * * @param titles A list with titles to search records for. */ @@ -125,14 +125,14 @@ public void setTitles(List titles) { /** * Gets the list of uid(s) to search for. * - * @return The list of uid(s) cooresponding to records for lookup. + * @return The list of uid(s) corresponding to records for lookup. */ public List getUids() { return uids; } /** - * Sets the list of uid(s) to preform a record search on. + * Sets the list of uid(s) to perform a record search on. * * @param uids A list with uid(s) to search records for. */ diff --git a/src/main/java/com/byteskeptical/credcat/util/Checks.java b/src/main/java/com/byteskeptical/credcat/util/Checks.java index c5c1d40..9bfce5f 100644 --- a/src/main/java/com/byteskeptical/credcat/util/Checks.java +++ b/src/main/java/com/byteskeptical/credcat/util/Checks.java @@ -9,14 +9,14 @@ * Null-tolerant value checks and normalizations. * *
      - *
    • {@link #isBase64(String)} -- valid standard base64?
    • - *
    • {@link #isJson(String)} -- valid JSON?
    • - *
    • {@link #isNullOrBlank(String)} -- treats whitespace as missing. The + *
    • {@link #isBase64(String)} valid standard base64?
    • + *
    • {@link #isJson(String)} valid JSON?
    • + *
    • {@link #isNullOrBlank(String)} treats whitespace as missing. The * right default for config values, request fields, identifiers.
    • - *
    • {@link #isNullOrEmpty(Collection)} -- the parallel emptiness check + *
    • {@link #isNullOrEmpty(Collection)} the parallel emptiness check * for any {@link Collection}; collections have no {@code isBlank()} * equivalent.
    • - *
    • {@link #trimToNull(String)} -- both checks and returns the cleaned + *
    • {@link #trimToNull(String)} both checks and returns the cleaned * value in one pass. The right tool for property reads from * hand-edited files where trailing whitespace is a real risk.
    • *
    diff --git a/src/main/resources/credcat.properties b/src/main/resources/credcat.properties index 636042b..21a3801 100644 --- a/src/main/resources/credcat.properties +++ b/src/main/resources/credcat.properties @@ -11,11 +11,11 @@ # - filesystem path to a config file (e.g. /etc/credcat/keeper.json) # - literal base64-encoded config string # - literal JSON config string -#keeper.config=/etc/credcat/configs/devops.json +#keeper.config=/etc/credcat/configs/dev.json # Optional: Directory holding multiple named configs (e.g. dev.json, qa.json, # prod.json). A request can pick which one to use via "configName": "dev". -# Tries .json, .b64, then no-extension when resolving a name. +# Tries .json, .b64, .base64, then no-extension when resolving a name. #keeper.config.dir=/etc/credcat/configs # Optional: Environment variable prefix for named configs. Looking up @@ -39,15 +39,15 @@ file.clean=true # Default file transport. Per-request override is "fileTransport" in JSON body. -# DISK -- write to keeper.files and return paths (best for CLI / shared FS) -# INLINE -- base64-encode into the JSON response (required on managed / -# restricted platforms) -# NONE -- skip file downloads, return metadata only +# DISK write to keeper.files and return paths (best for CLI / shared FS) +# INLINE base64-encode into the JSON response (required on managed / +# restricted platforms) +# NONE skip file downloads, return metadata only file.transport=INLINE # Optional: Defaults to OS temp directory: -# Windows: %USERPROFILE%\AppData\Local\Temp\credcat_* -# Linux/Mac: /tmp/credcat_* +# Windows: %USERPROFILE%\AppData\Local\Temp\credcat_* +# Linux/Mac: /tmp/credcat_* #keeper.files= # ================ @@ -64,3 +64,40 @@ server.port=8888 # Reject request bodies larger than this many bytes with HTTP 413. # Default 1 MiB. #server.max_request_bytes=1048576 + +# =========== +# TLS / HTTPS +# =========== + +# Serve HTTPS instead of plain HTTP. Default: Off. +#server.tls.enabled=false + +# Required when TLS is enabled. Keystore holding the server certificate and +# private key. +#server.tls.keystore=/etc/credcat/tls/server.p12 + +# Keystore password, resolved in order: +# 1. server.tls.keystore_password literal value +# 2. server.tls.keystore_password_env NAME of an env var holding it +# Prefer the env var route in production. Keeps the password out of this file +# PKCS12 is the default and recommended type. +#server.tls.keystore_password= +#server.tls.keystore_password_env=CREDCAT_KEYSTORE_PASSWORD +#server.tls.keystore_type=PKCS12 + +# Optional: Mutual TLS. NONE never asks for a client certificate, WANT asks +# but proceeds without one, NEED refuses the handshake without one. +# Default: NONE. +#server.tls.client_auth=NONE + +# Optional: Pin the enabled protocol versions, comma-separated. When unset, +# the JVM defaults apply. +#server.tls.protocols=TLSv1.3,TLSv1.2 + +# Optional: Truststore of client CAs, only consulted when client_auth is WANT +# or NEED. Without one, the JVM default trust anchors are used. Password +# resolution mirrors the keystore, literal first, then env var name. +#server.tls.truststore=/etc/credcat/tls/clients.p12 +#server.tls.truststore_password= +#server.tls.truststore_password_env= +#server.tls.truststore_type=PKCS12