From 3bc0e4fe882357c37e71e17364b5cf0f10b6d477 Mon Sep 17 00:00:00 2001
From: byteskeptical <40208858+byteskeptical@users.noreply.github.com>
Date: Fri, 5 Jun 2026 04:05:33 +0000
Subject: [PATCH 1/4] switching to the native central publishing plugin,
updating README, adding file transport options supporting more deployment
environments, adding named configs options allowing access to multiple
application vaults using configs in a defined directory or environment
variables, updating tests, per-request overrides for all new configuration
settings, updating credcat properties file, improving json handler, removing
empty fields from response, allowing max threads to be user-defined, improved
logging implementation.
---
.github/workflows/publish.yml | 18 +-
.github/workflows/test.yml | 4 +-
README.md | 124 ++-
checks.xml | 4 +-
pom.xml | 30 +-
.../byteskeptical/credcat/SecretsService.java | 740 ++++++++++++------
.../credcat/config/KeeperConfig.java | 240 ++++++
.../credcat/file/FileHandler.java | 178 +++++
.../credcat/file/FileTransport.java | 52 ++
.../credcat/model/KeeperRequest.java | 46 +-
.../credcat/model/SecretResponse.java | 95 ++-
.../byteskeptical/credcat/util/Checks.java | 123 +++
.../credcat/util/JsonHandler.java | 80 +-
src/main/resources/credcat.properties | 58 +-
src/main/resources/logging.properties | 5 +-
.../credcat/SecretsServiceTest.java | 134 ++--
.../credcat/util/ChecksTest.java | 61 ++
17 files changed, 1586 insertions(+), 406 deletions(-)
create mode 100644 src/main/java/com/byteskeptical/credcat/config/KeeperConfig.java
create mode 100644 src/main/java/com/byteskeptical/credcat/file/FileHandler.java
create mode 100644 src/main/java/com/byteskeptical/credcat/file/FileTransport.java
create mode 100644 src/main/java/com/byteskeptical/credcat/util/Checks.java
create mode 100755 src/test/java/com/byteskeptical/credcat/util/ChecksTest.java
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index b6e4fed..3b8ee87 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -16,9 +16,9 @@ jobs:
matrix:
os: [ubuntu-latest]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Setup Repository
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v6
with:
cache: 'maven'
check-latest: true
@@ -27,17 +27,17 @@ jobs:
gpg-private-key: ${{ secrets.GPG_KEY }}
java-package: 'jdk'
java-version: '21'
- server-id: ossrh
- server-password: OSSRH_PASSWORD
- server-username: OSSRH_USERNAME
+ server-id: central
+ server-password: CRENTRAL_PASSWORD
+ server-username: CRENTRAL_USERNAME
- name: Publish to the Maven Central Repository
- run: mvn -B -P ossrh -U deploy
+ run: mvn -B -P central -U deploy
env:
+ CENTRAL_PASSWORD: ${{ secrets.OSSRH_TOKEN }}
+ CENTRAL_USERNAME: ${{ secrets.OSSRH_USERNAME }}
GPG_KEY_PASS: ${{ secrets.GPG_KEY_PASS }}
- OSSRH_PASSWORD: ${{ secrets.OSSRH_TOKEN }}
- OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }}
- name: Publish to GitHub Packages
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v6
with:
cache: 'maven'
check-latest: true
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1fffc0d..dd14721 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -30,9 +30,9 @@ jobs:
java-version: '21'
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Java ${{ matrix.java-distribution }} ${{ matrix.java-version }}
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v6
with:
check-latest: true
distribution: ${{ matrix.java-distribution }}
diff --git a/README.md b/README.md
index 99fa5a6..fd10058 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,12 @@
Installation
- Usage
+
+ Usage
+
+
Roadmap
Contributing
License
@@ -133,7 +138,7 @@ packages like maven will be needed to utilize the provided pom file.
$jdk_url = "https://aka.ms/download-jdk/microsoft-jdk-21-windows-x64.msi"
$java_home = New-Item -ItemType Directory -Path "$env:ProgramFiles\Java" -Force
$maven_home = New-Item -ItemType Directory -Path "$env:ProgramFiles\Apache\Maven" -Force
- $maven_version = "3.9.11"
+ $maven_version = "3.9.16"
$maven_url = "https://dlcdn.apache.org/maven/maven-3/$maven_version/binaries/apache-maven-$maven_version-bin.zip"
Start-BitsTransfer -Destination "$env:USERPROFILE\Downloads\jdk-21.msi" -Source $jdk_url
Start-BitsTransfer -Destination "$env:USERPROFILE\Downloads\maven.zip" -Source $maven_url
@@ -180,16 +185,59 @@ 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.
+
+ ```properties
+ # Keeper
+ keeper.client_key= # one time token for dynamic config creation
+ keeper.config= # default device config: a path, raw json, or base64
+ keeper.config.dir= # directory searched for named (configName) configs
+ keeper.config.env= # env var prefix searched for named (configName) configs
+ keeper.files= # default save location (os temp dir when unset)
+ keeper.storage.persistent=false # persist SDK config mutations back to the source file
+
+ # Files
+ file.clean=true # wipe the files directory recursively on shutdown
+ file.transport=inline # disk | inline | none
+
+ # Server
+ server.host=127.0.0.1
+ server.port=8888
+ 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))
+ ```
+
+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.
+
+
+
## Usage
-You will need to generate a device config for your KSM application in either
-base64 or json format. You can also use the one time password feature to generate
-the config dynamically using the clientKey parameter instead. Using the config
-parameter provides the means to switch between application vaults. You can pass
-one or more of either titles and/or record uid's to retrive multiple records at
-once. Exact matches only. Any files are downloaded locally and their save
-location is returned in the response.
+You will need a device config for your KSM application in either base64 or json
+format. Provide it directly with the `config` parameter, as a literal value or a
+path to a file holding one. Skip the config entirely and let credcat mint one on
+the fly via the one time password feature with the `clientKey` parameter. When
+direct or individual handling of device configs is undesired use the `configName`
+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.
+
+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.
```sh
Usage: java -jar credcat.jar [ -server | '{ "config": ".keeper/config.base64", "titles": ["RECORD_TITLE"], "uids": ["RECORD_UID"] }' ]
@@ -197,48 +245,65 @@ location is returned in the response.
1. Payload can be any of the following.
```sh
- ADVANCED='{ "clientKey": "7dae669a419ee250d0fd0e12d527f5f1", "config": "config.base64", "saveLocation": "/mnt/share/keeper", "titles": ["development ldap"], "uids": ["chnmFhEC38YCHhNY1pA8Vg"] }'
+ ADVANCED='{ "clientKey": "7dae669a419ee250d0fd0e12d527f5f1", "config": "config.base64", "fileTransport": "disk", "saveLocation": "/mnt/share/keeper", "titles": ["development ldap"], "uids": ["chnmFhEC38YCHhNY1pA8Vg"] }'
+ NAMED='{ "configName": "production", "titles": ["Production ClickToCall API Key", "development ldap"] }'
TITLE_ONLY='{ "config": ".keeper/config.base64", "titles": ["Production ClickToCall API Key", "development ldap"] }'
- UID_ONLY='{ "config": ".keeper/config.base64", "uids": ["7bN_ceW-p3_alVUNmI09Tw", "chnmGhEC39YCHhNy1pA8vg"] }'
+ UID_ONLY='{ "config": ".keeper/config.base64", "fileTransport": "disk", "uids": ["7bN_ceW-p3_alVUNmI09Tw", "chnmGhEC39YCHhNy1pA8vg"] }'
```
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 -jar target/credcat.jar $UID_ONLY
+ java -cp "target/classes:target/dependency/*" com.byteskeptical.credcat.SecretsService "$ADVANCED"
+ java -jar target/credcat.jar "$UID_ONLY"
```
```json
INFO: {
"7bN_ceW-p3_alVUNmI09Tw" : {
- "notes" : null,
- "files" : [ ],
- "type" : "login",
- "title" : "development ldap",
"fields" : {
"password" : [ "bingbangboomdongle" ],
"login" : [ "ldaptest" ]
- }
+ },
+ "files" : [ ],
+ "title" : "development ldap",
+ "type" : "login"
},
"chnmGhEC39YCHhNy1pA8vg" : {
- "notes" : "VALUE = x-ClickToCall-APIKey:be0d988f-063c-d654-ad1b-a54337f87233",
+ "fields" : {
+ "password" : [ "be0d988f-063c-d654-ad1b-a54337f87233" ],
+ "login" : [ "integration.ucaas.call.metadata" ],
+ "fileref" : [ "3HcX3vCCvHBTBcOqCgCnsQ", "cGBiPmG_9GlZszFbsQmJea" ]
+ },
"files" : [ {
"name" : "ascii-art.txt",
- "path" : "/mnt/share/keeper-2452814181455428916/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" : "/mnt/share/keeper-2452814181455428916/integration.ucaas.call.metadata.PNG"
+ "path" : "/tmp/credcat_8f3a1c20-5e7b-4a9d-bd11-2c6f0e9a4477/integration.ucaas.call.metadata.PNG",
+ "mimeType" : "image/png",
+ "size" : 20480
} ],
- "type" : "login",
+ "notes" : "VALUE = x-ClickToCall-APIKey:be0d988f-063c-d654-ad1b-a54337f87233",
"title" : "Production ClickToCall API Key",
- "fields" : {
- "password" : [ "be0d988f-063c-d654-ad1b-a54337f87233" ],
- "login" : [ "integration.ucaas.call.metadata" ],
- "fileref" : [ "3HcX3vCCvHBTBcOqCgCnsQ", "cGBiPmG_9GlZszFbsQmJea" ]
+ "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
+ } ]
+ ```
+
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
@@ -246,8 +311,8 @@ location is returned in the response.
java -jar target/credcat.jar -server
```
```sh
- curl -d $UID_ONLY -H 'Content-Type: application/json' -v -XPOST http://127.0.0.1:8888/api/getSecrets
- curl -H 'Content-Type: application/json' -v http://127.0.0.1:8888/api/getVersion
+ curl -d "$UID_ONLY" -H 'Content-Type: application/json' -s -XPOST http://127.0.0.1:8888/api/getSecrets
+ curl -H 'Content-Type: application/json' -s http://127.0.0.1:8888/api/getVersion
```
@@ -263,6 +328,9 @@ location is returned in the response.
- [x] Handle all field types including files & notes
- [x] Handle title & uid searches
+- [x] Inline and metadata-only file transports for read-only & ephemeral hosts
+- [x] Named config resolution by directory or environment
+- [x] Per-request transport & save-location overrides
- [x] Retrieve more than one record in a single request
- [x] Support stand-alone and server modes
diff --git a/checks.xml b/checks.xml
index b8defd2..2320ccd 100644
--- a/checks.xml
+++ b/checks.xml
@@ -308,7 +308,7 @@
-
+
@@ -316,7 +316,7 @@
-
+
findRecords(
SecretsManagerOptions options, List titles, List uids
) {
- Set records = new HashSet<>();
+ boolean hasUids = !Checks.isNullOrEmpty(uids);
+ boolean hasTitles = !Checks.isNullOrEmpty(titles);
+
+ if (!hasUids && !hasTitles) {
+ return Collections.emptyList();
+ }
+
+ Map byUid = new LinkedHashMap<>();
try {
- if (uids != null && !uids.isEmpty()) {
- KeeperSecrets recordsByUid = SecretsManager.getSecrets(options, uids);
- records.addAll(recordsByUid.getRecords());
- LOGGER.log(Level.INFO, "Found {0} records by UID.", records.size());
- }
+ if (hasTitles) {
+ // Title resolution requires a full fetch; piggy-back UID matching on it.
+ KeeperSecrets all = SecretsManager.getSecrets(options);
+
+ if (hasUids) {
+ Set uidSet = new HashSet<>(uids);
+ for (KeeperRecord record : all.getRecords()) {
+ if (uidSet.contains(record.getRecordUid())) {
+ byUid.put(record.getRecordUid(), record);
+ }
+ }
+ LOGGER.log(Level.INFO,
+ "Matched {0} record(s) by UID from full fetch.", byUid.size());
+ }
- if (titles != null && !titles.isEmpty()) {
- KeeperSecrets recordsByTitle = SecretsManager.getSecrets(options);
for (String title : titles) {
- KeeperRecord record = recordsByTitle.getSecretByTitle(title);
+ KeeperRecord record = all.getSecretByTitle(title);
if (record != null) {
- records.add(record);
+ byUid.putIfAbsent(record.getRecordUid(), record);
LOGGER.log(Level.INFO,
- "Found record by title: ''{0}''", title);
+ "Found record by title: ''{0}''", title);
} else {
LOGGER.log(Level.WARNING,
- "Record with title ''{0}'' not found.", title);
+ "Record with title ''{0}'' not found.", title);
}
}
+ } else {
+ // UID-only path: let the SDK do the server-side filter.
+ KeeperSecrets byUidFetch = SecretsManager.getSecrets(options, uids);
+ for (KeeperRecord record : byUidFetch.getRecords()) {
+ byUid.put(record.getRecordUid(), record);
+ }
+ LOGGER.log(Level.INFO, "Found {0} record(s) by UID.", byUid.size());
}
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Retrieving record(s) failed!", e);
+ throw new RuntimeException(
+ "Failed to retrieve records: " + e.getMessage(), e);
}
- return new ArrayList<>(records);
+ return new ArrayList<>(byUid.values());
}
/**
@@ -238,40 +323,40 @@ List findRecords(
* @throws Exception if the request fails to process.
*/
public String getSecrets(String jsonRequest) throws Exception {
- LOGGER.log(Level.FINE, jsonRequest);
- KeeperRequest request = JsonHandler.fromJson(jsonRequest, KeeperRequest.class);
+ LOGGER.log(Level.FINE, "Received request payload of {0} bytes",
+ jsonRequest != null ? jsonRequest.length() : 0);
- String keeperConfig = request.getConfig();
- if (isNullOrEmpty(keeperConfig)) {
- if (appConfig.keeperConfig == null) {
- throw new IllegalArgumentException(
- "No Keeper config provided in request or properties."
- );
- }
- keeperConfig = appConfig.keeperConfig;
+ KeeperRequest request = JsonHandler.fromJson(jsonRequest, KeeperRequest.class);
+ if (request == null) {
+ throw new IllegalArgumentException("Request body is empty.");
}
- String clientKey = request.getClientKey();
- if (isNullOrEmpty(clientKey)) {
- if (appConfig.clientKey == null) {
- clientKey = null;
- } else {
- clientKey = appConfig.clientKey;
- }
+ KeeperConfig.KSM ksm = appConfig.keeperConfig.resolve(
+ request.getConfig(), request.getConfigName());
+ if (ksm == null) {
+ throw new IllegalArgumentException(
+ "No Keeper config provided in request or properties."
+ );
}
- String saveLocation = request.getSaveLocation();
- if (isNullOrEmpty(saveLocation)) {
- saveLocation = appConfig.filesLocation;
- }
+ String requestKey = request.getClientKey();
+ String clientKey = !Checks.isNullOrBlank(requestKey)
+ ? requestKey
+ : appConfig.clientKey;
- LocalConfigStorage storage = null;
+ FileTransport transport = FileTransport.parse(
+ request.getFileTransport(), appConfig.fileTransport);
+ String requestLocation = request.getSaveLocation();
+ String saveLocation = !Checks.isNullOrBlank(requestLocation)
+ ? requestLocation
+ : appConfig.filesDir.toString();
+
+ KeyValueStorage storage;
try {
- storage = new LocalConfigStorage(keeperConfig);
+ storage = buildStorage(ksm);
} catch (Exception e) {
- String errorMessage = "Loading of KSM vault config failed. Be sure "
- + keeperConfig
- + " contains a valid base64 encoded string or JSON object.";
+ String errorMessage = "Loading of KSM vault config failed. "
+ + "Ensure the config is valid base64-encoded or JSON.";
LOGGER.log(Level.SEVERE, errorMessage, e);
throw new RuntimeException(errorMessage, e);
}
@@ -280,54 +365,75 @@ public String getSecrets(String jsonRequest) throws Exception {
SecretsManager.initializeStorage(storage, clientKey, DOMAIN);
}
- SecretsManagerOptions options = null;
- options = new SecretsManagerOptions(
+ SecretsManagerOptions options = new SecretsManagerOptions(
storage, SecretsManager::cachingPostFunction
);
+ FileHandler fileHandler = FileHandler.forTransport(
+ transport, transport == FileTransport.DISK ? saveLocation : null);
+
List foundRecords = findRecords(
options, request.getTitles(), request.getUids()
);
Map> records = processRecords(
- foundRecords, saveLocation
+ foundRecords, fileHandler
);
return JsonHandler.toJson(records);
}
+ /**
+ * Picks the Keeper storage impl. In-memory by default; file-backed when
+ * persistent storage is enabled and the config came from a writable file.
+ */
+ 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;
+ }
+
+ 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());
+ }
+
/**
* Downloads files attached to a KeeperRecord, provides its metadata.
*
* @param files A list of KeeperFile entries usually from a KeeperRecord.
- * @param saveLocation Local path to save directory for record's files.
+ * @param handler The strategy to use for materializing each file.
* @return A list of name, path file object details for downloaded files.
- * @throws IOException if a file operation fails.
*/
List processFiles(
- List files, String saveLocation
- ) throws IOException {
- Path downloadDir = Path.of(saveLocation);
-
- if (!Files.exists(downloadDir)) {
- Files.createDirectories(downloadDir);
+ List files, FileHandler handler
+ ) {
+ if (Checks.isNullOrEmpty(files)) {
+ return Collections.emptyList();
}
- List fileInfos = new ArrayList<>();
+ List fileInfos = new ArrayList<>(files.size());
for (KeeperFile file : files) {
if (file == null) {
- System.out.println("Could not find file with UID: " + file + " in record");
+ LOGGER.log(Level.WARNING,
+ "Encountered null KeeperFile entry; skipping.");
continue;
}
- byte[] fileBytes = SecretsManager.downloadFile(file);
-
- String name = file.getData().getName();
- Path filePath = downloadDir.resolve(name);
- try (FileOutputStream fos = new FileOutputStream(filePath.toFile())) {
- fos.write(fileBytes);
- fileInfos.add(new SecretResponse.FileInfo(name, filePath.toString()));
- LOGGER.log(Level.INFO, "Successfully downloaded & saved file: {0}", name);
+ try {
+ fileInfos.add(handler.handle(file));
} catch (Exception e) {
- LOGGER.log(Level.SEVERE, "Saving file [" + name + "] failed!", e);
+ String name = file.getData() != null
+ ? file.getData().getName()
+ : file.getFileUid();
+ LOGGER.log(Level.SEVERE,
+ "Processing file [" + name + "] failed!", e);
}
}
@@ -337,15 +443,14 @@ List processFiles(
/**
* Process record(s) field(s) values, organize in a structured format.
*
- * @param record A KeeperRecord entry.
- * @param saveLocation Local path to save directory for record's files.
+ * @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.
- * @throws IOException if a file operation fails during processing.
*/
Map> processRecords(
- List records, String saveLocation
- ) throws IOException {
- Map> result = new HashMap<>();
+ List records, FileHandler fileHandler
+ ) {
+ Map> result = new LinkedHashMap<>();
for (KeeperRecord record : records) {
if (record == null) {
@@ -354,37 +459,35 @@ Map> processRecords(
KeeperRecordData recordData = record.getData();
final String recordUid = record.getRecordUid();
- Map recordDetails = new HashMap<>();
- Map> fieldsMap = new HashMap<>();
- List filesList = new ArrayList<>();
+ Map recordDetails = new LinkedHashMap<>();
+ Map> fieldsMap = new LinkedHashMap<>();
List fields = recordData.getFields();
List customFields = recordData.getCustom();
- Stream allFields = Stream.concat(fields.stream(),
- customFields.stream()
+ Stream allFields = Stream.concat(
+ fields != null ? fields.stream() : Stream.empty(),
+ customFields != null ? customFields.stream() : Stream.empty()
);
allFields.forEach(field -> {
String label = field.getLabel();
if (label == null) {
- label = field.getClass().getSimpleName().toLowerCase();
+ label = field.getClass().getSimpleName().toLowerCase(Locale.ROOT);
}
List values = xtraxField(field);
- if (values != null && !values.isEmpty() && !isNullOrEmpty(values.get(0))) {
+ if (hasValue(values)) {
fieldsMap.put(label, values);
} else {
LOGGER.log(Level.FINE,
- "Skipped empty field value for field: {0}", label
+ "Skipped empty field value for field: {0}", label
);
}
});
List files = record.getFiles();
- if (files != null) {
- filesList.addAll(processFiles(files, saveLocation));
- }
+ List filesList = processFiles(files, fileHandler);
recordDetails.put("fields", fieldsMap);
recordDetails.put("files", filesList);
@@ -397,6 +500,53 @@ Map> processRecords(
return result;
}
+ /**
+ * 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;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 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());
+ }
+
+ /**
+ * Formats epoch-millis timestamps as ISO-8601 UTC. Used identically by
+ * {@code BirthDate}, {@code Date}, and {@code ExpirationDate}
+ *
+ * @param timestamps A list of epoch-millisecond values; may be {@code null}.
+ * @return ISO-8601 string forms.
+ */
+ private static List formatTimestamps(List timestamps) {
+ return mapValues(timestamps, ts -> Instant.ofEpochMilli(ts).toString());
+ }
+
/**
* Extracts the value(s) from a KeeperRecordField to a data type. My shame :(
*
@@ -411,139 +561,97 @@ List xtraxField(KeeperRecordField field) {
} else if (field instanceof AddressRef) {
return ((AddressRef) field).getValue();
} else if (field instanceof BankAccounts) {
- return ((BankAccounts) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(ba -> {
- return String.format("Type: %s, Routing: %s, Account: %s, Other: %s",
- ba.getAccountType(), ba.getRoutingNumber(),
- ba.getAccountNumber(), ba.getOtherType()
- );
- })
- .collect(Collectors.toList());
+ return mapValues(((BankAccounts) field).getValue(),
+ ba -> String.format("Type: %s, Routing: %s, Account: %s, Other: %s",
+ ba.getAccountType(), ba.getRoutingNumber(),
+ ba.getAccountNumber(), ba.getOtherType()));
} else if (field instanceof BirthDate) {
- return ((BirthDate) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(timestamp -> new java.util.Date(timestamp).toString())
- .collect(Collectors.toList());
+ return formatTimestamps(((BirthDate) field).getValue());
} else if (field instanceof CardRef) {
return ((CardRef) field).getValue();
} else if (field instanceof Date) {
- return ((Date) field).getValue().stream()
- .map(Object::toString)
- .collect(Collectors.toList());
+ return formatTimestamps(((Date) field).getValue());
} else if (field instanceof Email) {
return ((Email) field).getValue();
} else if (field instanceof ExpirationDate) {
- return ((ExpirationDate) field).getValue().stream()
- .map(Object::toString)
- .collect(Collectors.toList());
+ return formatTimestamps(((ExpirationDate) field).getValue());
} else if (field instanceof FileRef) {
return ((FileRef) field).getValue();
} else if (field instanceof HiddenField) {
return ((HiddenField) field).getValue();
} else if (field instanceof Hosts) {
- return ((Hosts) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(h -> {
- return String.format("%s:%s", h.getHostName(), h.getPort());
- })
- .collect(Collectors.toList());
+ return mapValues(((Hosts) field).getValue(),
+ h -> String.format("%s:%s", h.getHostName(), h.getPort()));
} else if (field instanceof KeyPairs) {
- return ((KeyPairs) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(kp -> {
- return String.format("Public Key: %s, Private Key: %s",
- kp.getPrivateKey(), kp.getPublicKey()
- );
- })
- .collect(Collectors.toList());
+ return mapValues(((KeyPairs) field).getValue(),
+ kp -> String.format("Public Key: %s, Private Key: %s",
+ kp.getPublicKey(), kp.getPrivateKey()));
} else if (field instanceof LicenseNumber) {
return ((LicenseNumber) field).getValue();
} else if (field instanceof Multiline) {
return ((Multiline) field).getValue();
} else if (field instanceof Names) {
- return ((Names) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(n -> {
- return String.format("%s %s %s",
- n.getFirst() != null ? n.getFirst() : "",
- n.getMiddle() != null ? n.getMiddle() : "",
- n.getLast() != null ? n.getLast() : ""
- ).trim();
- })
- .collect(Collectors.toList());
+ return mapValues(((Names) field).getValue(),
+ n -> String.format("%s %s %s",
+ n.getFirst() != null ? n.getFirst() : "",
+ n.getMiddle() != null ? n.getMiddle() : "",
+ n.getLast() != null ? n.getLast() : "")
+ .trim().replaceAll("\\s+", " "));
} else if (field instanceof OneTimeCode) {
- return ((OneTimeCode) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(url -> {
- TotpCode totp = TotpCode.uriToTotpCode(url);
- return List.of(
- totp.getCode(),
- String.valueOf(totp.getTimeLeft())
- );
- })
- .flatMap(List::stream)
- .collect(Collectors.toList());
+ // OneTimeCode emits two values per source URL (code + timeLeft),
+ // so it's the one case mapValues can't fully cover
+ List urls = ((OneTimeCode) field).getValue();
+ if (Checks.isNullOrEmpty(urls)) {
+ return Collections.emptyList();
+ }
+
+ List result = new ArrayList<>(urls.size() * 2);
+ for (String url : urls) {
+ if (url == null) {
+ continue;
+ }
+
+ TotpCode totp = TotpCode.uriToTotpCode(url);
+ result.add(totp.getCode());
+ result.add(String.valueOf(totp.getTimeLeft()));
+ }
+
+ return result;
} else if (field instanceof OneTimePassword) {
return ((OneTimePassword) field).getValue();
} else if (field instanceof Passkeys) {
- return ((Passkeys) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(pk -> {
- return String.format("%s, %s, %s, %s, %s, %s, %s",
- pk.getCredentialId(),
- pk.getSignCount(),
- pk.getUserId(),
- pk.getRelyingParty(),
- pk.getUsername(),
- pk.getCreatedDate(),
- pk.getPrivateKey()
- );
- })
- .collect(Collectors.toList());
+ return mapValues(((Passkeys) field).getValue(),
+ pk -> String.format("%s, %s, %s, %s, %s, %s, %s",
+ pk.getCredentialId(), pk.getSignCount(),
+ pk.getUserId(), pk.getRelyingParty(),
+ pk.getUsername(), pk.getCreatedDate(),
+ pk.getPrivateKey()));
} else if (field instanceof Password) {
return ((Password) field).getValue();
} else if (field instanceof PaymentCards) {
- return ((PaymentCards) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(pc -> {
- return String.format("%s, %s, %s",
- pc.getCardNumber(),
- pc.getCardExpirationDate(),
- pc.getCardSecurityCode()
- );
- })
- .collect(Collectors.toList());
+ return mapValues(((PaymentCards) field).getValue(),
+ pc -> String.format("%s, %s, %s",
+ pc.getCardNumber(),
+ pc.getCardExpirationDate(),
+ pc.getCardSecurityCode()));
} else if (field instanceof Phones) {
- return ((Phones) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(p -> {
- return String.format("%s, %s",
- p.getType(), p.getNumber()
- );
- })
- .collect(Collectors.toList());
+ return mapValues(((Phones) field).getValue(),
+ p -> String.format("%s, %s", p.getType(), p.getNumber()));
} else if (field instanceof PinCode) {
return ((PinCode) field).getValue();
} else if (field instanceof SecureNote) {
return ((SecureNote) field).getValue();
} else if (field instanceof SecurityQuestions) {
- return ((SecurityQuestions) field).getValue().stream()
- .filter(Objects::nonNull)
- .map(sq -> {
- return String.format("%s, %s",
- sq.getQuestion(), sq.getAnswer()
- );
- })
- .collect(Collectors.toList());
+ return mapValues(((SecurityQuestions) field).getValue(),
+ sq -> String.format("%s, %s", sq.getQuestion(), sq.getAnswer()));
} else if (field instanceof Text) {
return ((Text) field).getValue();
} else if (field instanceof Url) {
return ((Url) field).getValue();
} else {
LOGGER.log(Level.WARNING,
- "Skipped strange & unexpected field type: {0}",
- field.getClass().getName()
+ "Skipped strange & unexpected field type: {0}",
+ field.getClass().getName()
);
return Collections.emptyList();
@@ -563,41 +671,81 @@ private static void sendResponse(
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.
*/
static class SecretsHandler implements HttpHandler {
private final SecretsService service;
+ private final int maxRequestBytes;
SecretsHandler(SecretsService service) {
this.service = service;
+ this.maxRequestBytes = service.appConfig.maxRequestBytes;
}
@Override
public void handle(HttpExchange exchange) throws IOException {
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
- sendResponse(exchange, 405, "{\"error\": \"Method Not Allowed\"}");
+ sendResponse(exchange, 405,
+ JsonHandler.envelope("error", "Method Not Allowed"));
return;
}
try (InputStream is = exchange.getRequestBody()) {
- String request = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ byte[] body = readRequest(is, maxRequestBytes);
+
+ if (body == null) {
+ sendResponse(exchange, 413, JsonHandler.envelope("error",
+ "Request body exceeds " + maxRequestBytes + " bytes"));
+ return;
+ }
+
+ String request = new String(body, StandardCharsets.UTF_8);
String response = service.getSecrets(request);
sendResponse(exchange, 200, response);
} catch (IllegalArgumentException e) {
LOGGER.warning("Bad Request: " + e.getMessage());
- sendResponse(exchange, 400, "{\"error\": \"" + e.getMessage() + "\"}");
+ sendResponse(exchange, 400,
+ JsonHandler.envelope("error", e.getMessage()));
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Internal Error", e);
- sendResponse(exchange, 500,
- "{\"error\": \"Internal Server Error: " + e.getMessage() + "\"}"
- );
+ String detail = e.getMessage() != null
+ ? e.getMessage()
+ : e.getClass().getSimpleName();
+ sendResponse(exchange, 500, JsonHandler.envelope("error",
+ "Internal Server Error: " + detail));
}
}
}
@@ -616,14 +764,94 @@ static class VersionHandler implements HttpHandler {
public void handle(HttpExchange exchange) throws IOException {
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
sendResponse(exchange, 405,
- "{\"error\": \"Method Not Allowed\"}"
- );
+ JsonHandler.envelope("error", "Method Not Allowed"));
return;
}
sendResponse(exchange, 200,
- "{\"version\": \"" + service.getVersion() + "\"}"
- );
+ JsonHandler.envelope("version", service.getVersion()));
+ }
+ }
+
+ /**
+ * 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);
}
}
@@ -636,18 +864,24 @@ public static void main(String[] args) {
long startTime = System.currentTimeMillis();
if (args.length == 0) {
- System.out.println("Usage: java -jar credcat.jar [-server | '']\n");
+ System.out.println(
+ "Usage: java -jar credcat.jar "
+ + "[-server | '']\n"
+ );
System.out.println(
"Example: java -jar credcat.jar '{"
- + "\"config\":\"config.json\", "
- + "\"titles\":[\"RECORD_TITLES\"], \"uids\":[\"RECORD_UID\"]}'"
+ + "\"configName\":\"dev\", "
+ + "\"titles\":[\"RECORD_TITLES\"], \"uids\":[\"RECORD_UID\"], "
+ + "\"fileTransport\":\"INLINE\"}'"
);
return;
}
+ ExecutorService executor = null;
+
try {
- AppConfig config = new AppConfig();
+ final AppConfig config = new AppConfig();
SecretsService service = new SecretsService(config);
if (args[0].equals("-server")) {
@@ -664,38 +898,56 @@ public static void main(String[] args) {
new VersionHandler(service)
);
- server.setExecutor(EXECUTOR);
+ executor = new ThreadPoolExecutor(
+ config.threads, config.threads,
+ 60L, TimeUnit.SECONDS,
+ new ArrayBlockingQueue<>(config.threads * 4),
+ new ThreadPoolExecutor.CallerRunsPolicy()
+ );
+ final ExecutorService finalExecutor = executor;
+
+ server.setExecutor(executor);
server.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOGGER.info("Herding credcat for you...");
server.stop(2);
- EXECUTOR.shutdown();
+ finalExecutor.shutdown();
try {
- if (!EXECUTOR.awaitTermination(5, TimeUnit.SECONDS)) {
- EXECUTOR.shutdownNow();
+ if (!finalExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
+ finalExecutor.shutdownNow();
}
} catch (InterruptedException e) {
- EXECUTOR.shutdownNow();
+ finalExecutor.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+
+ if (config.autoClean) {
+ cleanFilesDir(config.filesDir);
}
}));
+ long elapsed = System.currentTimeMillis() - startTime;
LOGGER.log(Level.INFO,
"Credcat started meowing in {0}ms on {1}:{2,number,#}",
- new Object[] {
- (System.currentTimeMillis() - startTime),
- config.host,
- config.port
- }
- );
+ new Object[] { elapsed, config.host, config.port });
} else {
- String response = service.getSecrets(args[0]);
- LOGGER.log(Level.INFO, "--- Secrets Found ---");
- System.out.println(response);
+ try {
+ String response = service.getSecrets(args[0]);
+ LOGGER.log(Level.INFO, "--- Secrets Found ---");
+ System.out.println(response);
+ } finally {
+ if (config.autoClean) {
+ cleanFilesDir(config.filesDir);
+ }
+ }
}
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Execution failed in main method.", e);
+ if (executor != null) {
+ executor.shutdownNow();
+ }
}
}
}
diff --git a/src/main/java/com/byteskeptical/credcat/config/KeeperConfig.java b/src/main/java/com/byteskeptical/credcat/config/KeeperConfig.java
new file mode 100644
index 0000000..524db6e
--- /dev/null
+++ b/src/main/java/com/byteskeptical/credcat/config/KeeperConfig.java
@@ -0,0 +1,240 @@
+package com.byteskeptical.credcat.config;
+
+import com.byteskeptical.credcat.util.Checks;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+/**
+ * Resolves the KSM device config from a request value, a named lookup,
+ * or the service-wide default in that order.
+ *
+ *
+ * - An explicit literal in the request body ({@code config}) -- treated as
+ * either a filesystem path or raw base64/JSON content.
+ * - A named config in the request body ({@code configName}) -- looked up
+ * against the configured directory and environment variable prefix
+ * (in that order).
+ * - The service default ({@code keeper.config} from properties).
+ *
+ *
+ * This class never writes to disk and never logs the resolved contents.
+ */
+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 final String configContent;
+ private final Path configDir;
+ private final String envPrefix;
+
+ /**
+ * Builds a resolver over an optional inline default, a named config
+ * directory, and an env var prefix.
+ *
+ * @param configContent The resolved {@code keeper.config} payload, or
+ * {@code null} to disable the default.
+ * @param configDir Directory holding named configs, or {@code null}.
+ * @param envPrefix Env var prefix for named configs
+ * (e.g. {@code "KEEPER_CONFIG_"}), or {@code null}.
+ */
+ public KeeperConfig(String configContent, Path configDir, String envPrefix) {
+ this.configContent = configContent;
+ this.configDir = configDir;
+ this.envPrefix = envPrefix;
+ }
+
+ /**
+ * A resolved KSM device config along with where it came from.
+ */
+ public static final class KSM {
+ private final String content;
+ private final String origin;
+
+ KSM(String content, String origin) {
+ this.content = content;
+ this.origin = origin;
+ }
+
+ /**
+ * Retrieve the resolved config payload.
+ *
+ * @return The config payload (JSON or base64). Never {@code null}.
+ */
+ public String getContent() {
+ return content;
+ }
+
+ /**
+ * Where did thow config come from?.
+ *
+ * @return Absolute path for file sourced configs, env var name for
+ * env sourced configs, or {@code null} for request literals
+ * and the inline default.
+ */
+ public String getOrigin() {
+ return origin;
+ }
+ }
+
+ /**
+ * Resolves the config for an incoming request.
+ *
+ * @param config Path, JSON, or base64; may be {@code null}.
+ * @param configName Symbolic name to look up; may be {@code null}.
+ * @return The resolved config, or {@code null} if nothing matched.
+ */
+ public KSM resolve(String config, String configName) {
+ if (!Checks.isNullOrBlank(config)) {
+ return interpret(config);
+ }
+
+ if (!Checks.isNullOrBlank(configName)) {
+ KSM named = configByName(configName);
+ if (named != null) {
+ return named;
+ }
+ LOGGER.log(Level.WARNING, "Named config ''{0}'' not found.", configName);
+ }
+
+ if (!Checks.isNullOrBlank(configContent)) {
+ return new KSM(configContent, null);
+ }
+
+ return null;
+ }
+
+ /**
+ * Interprets a raw value as a filesystem path, JSON literal, or base64.
+ *
+ * @param raw The value to interpret.
+ * @return The resolved config. Never {@code null}.
+ * @throws IllegalArgumentException if {@code raw} is none of the three.
+ */
+ public KSM interpret(String raw) {
+ Objects.requireNonNull(raw, "raw");
+ Path path = null;
+
+ // Multi-line or very long strings can't plausibly be paths, skip
+ if (raw.indexOf('\n') < 0 && raw.indexOf('\r') < 0 && raw.length() <= 4096) {
+ try {
+ path = Path.of(raw);
+ } catch (InvalidPathException ignored) {
+ // not a usable path; fall through to literal parsing
+ }
+ }
+
+ if (path != null && Files.isRegularFile(path)) {
+ try {
+ return new KSM(
+ Files.readString(path, StandardCharsets.UTF_8),
+ path.toString());
+ } catch (IOException e) {
+ LOGGER.log(Level.SEVERE, "Failed to read Keeper config file.", e);
+ }
+ }
+
+ if (Checks.isJson(raw) || Checks.isBase64(raw)) {
+ return new KSM(raw, null);
+ }
+
+ throw new IllegalArgumentException(
+ "config is neither a readable file path, JSON, nor base64");
+ }
+
+ /**
+ * Looks up a config by symbolic name in the configured directory and env
+ * vars, in that order.
+ */
+ private KSM configByName(String name) {
+ String safe = name.replaceAll("[^\\w.-]", "");
+
+ if (Checks.isNullOrBlank(safe)) {
+ LOGGER.log(Level.WARNING, "Rejected unsafe config name: ''{0}''", name);
+ return null;
+ }
+
+ if (configDir != null) {
+ for (String ext : EXTENSIONS) {
+ Path candidate = configDir.resolve(safe + ext);
+
+ if (Files.isRegularFile(candidate)) {
+ try {
+ return new KSM(
+ Files.readString(candidate, StandardCharsets.UTF_8),
+ candidate.toString());
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING,
+ "Failed to read named config from " + candidate, e);
+ }
+ }
+ }
+ }
+
+ if (!Checks.isNullOrBlank(envPrefix)) {
+ String envName = envPrefix + safe.toUpperCase(Locale.ROOT);
+ String value = Checks.trimToNull(System.getenv(envName));
+
+ if (value != null) {
+ return new KSM(value, envName);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Every named config visible across all sources.
+ *
+ * @return Sorted list of named configs visible across all sources.
+ */
+ public List listConfigs() {
+ Set names = new TreeSet<>();
+
+ if (configDir != null && Files.isDirectory(configDir)) {
+ try (Stream entries = Files.list(configDir)) {
+ entries.filter(Files::isRegularFile)
+ .map(p -> p.getFileName().toString())
+ .map(KeeperConfig::stripKnownExt)
+ .filter(s -> !Checks.isNullOrBlank(s))
+ .forEach(names::add);
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to list config directory.", e);
+ }
+ }
+
+ if (!Checks.isNullOrBlank(envPrefix)) {
+ for (String key : System.getenv().keySet()) {
+ if (key.startsWith(envPrefix)) {
+ names.add(key.substring(envPrefix.length()).toLowerCase(Locale.ROOT));
+ }
+ }
+ }
+
+ return new ArrayList<>(names);
+ }
+
+ private static String stripKnownExt(String filename) {
+ for (String ext : EXTENSIONS) {
+ if (!Checks.isNullOrBlank(ext) && filename.endsWith(ext)) {
+ return filename.substring(0, filename.length() - ext.length());
+ }
+ }
+
+ return filename;
+ }
+}
diff --git a/src/main/java/com/byteskeptical/credcat/file/FileHandler.java b/src/main/java/com/byteskeptical/credcat/file/FileHandler.java
new file mode 100644
index 0000000..de19ed8
--- /dev/null
+++ b/src/main/java/com/byteskeptical/credcat/file/FileHandler.java
@@ -0,0 +1,178 @@
+package com.byteskeptical.credcat.file;
+
+import com.byteskeptical.credcat.model.SecretResponse;
+import com.byteskeptical.credcat.util.Checks;
+import com.keepersecurity.secretsManager.core.KeeperFile;
+import com.keepersecurity.secretsManager.core.KeeperFileData;
+import com.keepersecurity.secretsManager.core.SecretsManager;
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Materializing a KeeperFile.
+ */
+public interface FileHandler {
+
+ /** Shared logger for the file handlers. */
+ Logger LOGGER = Logger.getLogger(FileHandler.class.getName());
+
+ /**
+ * Downloads or skips a single attachment and produces the FileInfo entry
+ * for inclusion in the JSON response.
+ *
+ * @param file The KeeperFile to process. Never {@code null}.
+ * @return A non-null FileInfo describing the result.
+ * @throws IOException if the underlying download or persistence fails.
+ */
+ SecretResponse.FileInfo handle(KeeperFile file) throws IOException;
+
+ /**
+ * Factory for transport handling and an optional save location.
+ *
+ * @param transport Which transport, pick one. Must not be {@code null}.
+ * @param saveLocation The directory to write files into when DISK is used.
+ * Ignored otherwise.
+ * @return An initialized handler.
+ * @throws IOException if DISK is selected and the directory cannot be created.
+ */
+ static FileHandler forTransport(FileTransport transport, String saveLocation)
+ throws IOException {
+ Objects.requireNonNull(transport, "transport");
+
+ switch (transport) {
+ case DISK:
+ return new Disk(saveLocation);
+ case INLINE:
+ return new Inline();
+ case NONE:
+ return new None();
+ default:
+ throw new IllegalStateException("Unhandled transport: " + transport);
+ }
+ }
+
+ /**
+ * Pulls the metadata trifecta (name, mime, size) out of a KeeperFile,
+ * tolerating the case where {@code KeeperFile.getData()} returns
+ * {@code null}.
+ *
+ * @param file The KeeperFile to inspect.
+ * @return KeeperFile metadata; never {@code null}.
+ */
+ static Metadata metadataFor(KeeperFile file) {
+ KeeperFileData data = file.getData();
+
+ if (data == null) {
+ return new Metadata(file.getFileUid(), null, null);
+ }
+
+ return new Metadata(data.getName(), data.getType(), data.getSize());
+ }
+
+ /**
+ * Value holder for the {@link KeeperFileData} bits used by the
+ * handlers. Built via {@link FileHandler#metadataFor(KeeperFile)}.
+ */
+ final class Metadata {
+ final String mime;
+ final String name;
+ final Long size;
+
+ Metadata(String name, String mime, Long size) {
+ this.mime = mime;
+ this.name = name;
+ this.size = size;
+ }
+ }
+
+ /**
+ * Writes a file to local disk and returns its path. Buffered so we don't
+ * issue a syscall per byte. The Keeper SDK exposes {@code downloadFile}
+ * only as {@code byte[]}. Streaming isn't possible from source, we still
+ * want to avoid two copies in memory and use a buffered write for syscall
+ * amortization.
+ */
+ final class Disk implements FileHandler {
+ private static final int BUFFER_SIZE = 64 * 1024;
+
+ private final Path downloadDir;
+
+ Disk(String saveLocation) throws IOException {
+ if (Checks.isNullOrBlank(saveLocation)) {
+ throw new IOException("DISK transport requires a save location.");
+ }
+
+ this.downloadDir = Path.of(saveLocation);
+ Files.createDirectories(this.downloadDir);
+ }
+
+ @Override
+ public SecretResponse.FileInfo handle(KeeperFile file) throws IOException {
+ byte[] bytes = SecretsManager.downloadFile(file);
+
+ Metadata m = metadataFor(file);
+ Path target = downloadDir.resolve(sanitize(m.name));
+
+ try (OutputStream os = new BufferedOutputStream(
+ Files.newOutputStream(target), BUFFER_SIZE)) {
+ os.write(bytes);
+ }
+
+ LOGGER.log(Level.INFO,
+ "Saved attachment ''{0}'' ({1} bytes) to {2}",
+ new Object[] { m.name, bytes.length, target });
+
+ return SecretResponse.FileInfo.forDisk(
+ m.name, target.toString(), m.mime, (long) bytes.length);
+ }
+
+ private static String sanitize(String name) {
+ if (Checks.isNullOrBlank(name)) {
+ return "unnamed";
+ }
+
+ return name.replace('/', '_').replace('\\', '_');
+ }
+ }
+
+ /**
+ * Holds the file's bytes in memory and returns base64-encoded content.
+ * The only transport that survives a read-only filesystem.
+ */
+ final class Inline implements FileHandler {
+
+ @Override
+ public SecretResponse.FileInfo handle(KeeperFile file) throws IOException {
+ byte[] bytes = SecretsManager.downloadFile(file);
+
+ Metadata m = metadataFor(file);
+ String encoded = Base64.getEncoder().encodeToString(bytes);
+
+ LOGGER.log(Level.INFO,
+ "Inlined attachment ''{0}'' ({1} bytes)",
+ new Object[] { m.name, bytes.length });
+
+ return SecretResponse.FileInfo.forInline(
+ m.name, encoded, m.mime, (long) bytes.length);
+ }
+ }
+
+ /**
+ * Metadata only. No download is performed, nothing leaves Keeper.
+ */
+ final class None implements FileHandler {
+
+ @Override
+ public SecretResponse.FileInfo handle(KeeperFile file) {
+ Metadata m = metadataFor(file);
+ return SecretResponse.FileInfo.skipped(m.name, m.mime, m.size);
+ }
+ }
+}
diff --git a/src/main/java/com/byteskeptical/credcat/file/FileTransport.java b/src/main/java/com/byteskeptical/credcat/file/FileTransport.java
new file mode 100644
index 0000000..a42924e
--- /dev/null
+++ b/src/main/java/com/byteskeptical/credcat/file/FileTransport.java
@@ -0,0 +1,52 @@
+package com.byteskeptical.credcat.file;
+
+import com.byteskeptical.credcat.util.Checks;
+import java.util.Locale;
+
+/**
+ * How the service should hand attached files back to the caller.
+ *
+ * Selectable per-request, falls back to a service-wide default. The correct
+ * answer depends on the deployment:
+ *
+ *
+ * - {@link #DISK} -- Write files to local storage and return their paths.
+ * Best when the consumer runs on the same host or shared filesystem.
+ * - {@link #INLINE} -- Base64-encode the file content into the JSON
+ * response. The only viable option in sandboxed environments where the
+ * local filesystem is unwritable or ephemeral.
+ * - {@link #NONE} -- Skip downloads entirely and return metadata only.
+ * Cheapest path when the caller just needs field values.
+ *
+ */
+public enum FileTransport {
+ /** Write files to local storage and return their paths. */
+ DISK,
+ /** Base64-encode the file content directly into the JSON response. */
+ INLINE,
+ /** Skip downloads; return file metadata only. */
+ NONE;
+
+ /**
+ * Lenient parser for the files configuration value. Ignores case, falls
+ * back to the supplied default when the input is null, blank, or
+ * unrecognized.
+ *
+ * @param value The textual value to parse. May be {@code null}.
+ * @param fallback The value to use when {@code value} cannot be parsed.
+ * @return A non-null FileTransport.
+ */
+ public static FileTransport parse(String value, FileTransport fallback) {
+ String trimmed = Checks.trimToNull(value);
+
+ if (trimmed == null) {
+ return fallback;
+ }
+
+ try {
+ return FileTransport.valueOf(trimmed.toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ return fallback;
+ }
+ }
+}
diff --git a/src/main/java/com/byteskeptical/credcat/model/KeeperRequest.java b/src/main/java/com/byteskeptical/credcat/model/KeeperRequest.java
index 6e14bfb..7768add 100644
--- a/src/main/java/com/byteskeptical/credcat/model/KeeperRequest.java
+++ b/src/main/java/com/byteskeptical/credcat/model/KeeperRequest.java
@@ -8,28 +8,48 @@
public class KeeperRequest {
private String clientKey;
private String config;
+ private String configName;
+ private String fileTransport;
private String saveLocation;
private List titles;
private List uids;
/**
- * Gets the configuration path.
+ * Gets the configuration path or literal.
*
- * @return The configuration path.
+ * @return The configuration value.
*/
public String getConfig() {
return config;
}
/**
- * Sets the configuration path.
+ * Sets the configuration path or literal.
*
- * @param config The configuration path to set.
+ * @param config The configuration value to set.
*/
public void setConfig(String config) {
this.config = config;
}
+ /**
+ * Gets the symbolic config name to look up (e.g. "dev").
+ *
+ * @return The config name.
+ */
+ public String getConfigName() {
+ return configName;
+ }
+
+ /**
+ * Sets the symbolic config name to look up.
+ *
+ * @param configName The config name to set.
+ */
+ public void setConfigName(String configName) {
+ this.configName = configName;
+ }
+
/**
* Retrieves the client key.
*
@@ -48,6 +68,24 @@ public void setClientKey(String clientKey) {
this.clientKey = clientKey;
}
+ /**
+ * Gets the per-request file transport override (DISK, INLINE, NONE).
+ *
+ * @return The file transport value.
+ */
+ public String getFileTransport() {
+ return fileTransport;
+ }
+
+ /**
+ * Sets the per-request file transport override.
+ *
+ * @param fileTransport The file transport value to set.
+ */
+ public void setFileTransport(String fileTransport) {
+ this.fileTransport = fileTransport;
+ }
+
/**
* Gets the save location for downloaded files.
*
diff --git a/src/main/java/com/byteskeptical/credcat/model/SecretResponse.java b/src/main/java/com/byteskeptical/credcat/model/SecretResponse.java
index 445d62a..225b9a8 100644
--- a/src/main/java/com/byteskeptical/credcat/model/SecretResponse.java
+++ b/src/main/java/com/byteskeptical/credcat/model/SecretResponse.java
@@ -1,26 +1,80 @@
package com.byteskeptical.credcat.model;
+import com.fasterxml.jackson.annotation.JsonInclude;
+
/**
* Record(s) fields format.
*/
public class SecretResponse {
/**
- * Minimal file structure.
+ * File structure. Carries a path (DISK), base64 content (INLINE), or
+ * neither (NONE). {@code @JsonInclude} drops whichever isn't set.
*/
+ @JsonInclude(JsonInclude.Include.NON_NULL)
public static class FileInfo {
- private String name;
- private String path;
+ private final String content;
+ private final String mimeType;
+ private final String name;
+ private final String path;
+ private final Long size;
/**
* Constructs a new FileInfo object.
*
- * @param name The name of the file.
- * @param path The path to the file on the filesystem.
+ * @param name The name of the file.
+ * @param path Filesystem path to the saved file, or {@code null}.
+ * @param content Base64-encoded file content, or {@code null}.
+ * @param mimeType The MIME type, or {@code null} if unknown.
+ * @param size File size in bytes, or {@code null} if unknown.
*/
- public FileInfo(String name, String path) {
+ public FileInfo(String name, String path, String content,
+ String mimeType, Long size) {
+ this.content = content;
+ this.mimeType = mimeType;
this.name = name;
this.path = path;
+ this.size = size;
+ }
+
+ /**
+ * Builds a FileInfo for a file written to local disk.
+ *
+ * @param name The name of the file.
+ * @param path The path to the saved file.
+ * @param mimeType The MIME type, or {@code null} if unknown.
+ * @param size File size in bytes, or {@code null} if unknown.
+ * @return A disk-backed FileInfo.
+ */
+ public static FileInfo forDisk(String name, String path,
+ String mimeType, Long size) {
+ return new FileInfo(name, path, null, mimeType, size);
+ }
+
+ /**
+ * Builds a FileInfo carrying base64 content inline.
+ *
+ * @param name The name of the file.
+ * @param content Base64-encoded file content.
+ * @param mimeType The MIME type, or {@code null} if unknown.
+ * @param size File size in bytes, or {@code null} if unknown.
+ * @return An inline FileInfo.
+ */
+ public static FileInfo forInline(String name, String content,
+ String mimeType, Long size) {
+ return new FileInfo(name, null, content, mimeType, size);
+ }
+
+ /**
+ * Builds a metadata-only FileInfo, no path, no payload.
+ *
+ * @param name The name of the file.
+ * @param mimeType The MIME type, or {@code null} if unknown.
+ * @param size File size in bytes, or {@code null} if unknown.
+ * @return A metadata-only FileInfo.
+ */
+ public static FileInfo skipped(String name, String mimeType, Long size) {
+ return new FileInfo(name, null, null, mimeType, size);
}
/**
@@ -35,10 +89,37 @@ public String getName() {
/**
* Retrieves the path to the file.
*
- * @return The file path.
+ * @return The file path, or {@code null} if not written to disk.
*/
public String getPath() {
return path;
}
+
+ /**
+ * Retrieves the base64-encoded file content.
+ *
+ * @return The content, or {@code null} for non-inline transports.
+ */
+ public String getContent() {
+ return content;
+ }
+
+ /**
+ * Retrieves the MIME type of the file.
+ *
+ * @return The MIME type, or {@code null} if unknown.
+ */
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ /**
+ * Retrieves the size of the file.
+ *
+ * @return File size in bytes, or {@code null} if unknown.
+ */
+ public Long getSize() {
+ return size;
+ }
}
}
diff --git a/src/main/java/com/byteskeptical/credcat/util/Checks.java b/src/main/java/com/byteskeptical/credcat/util/Checks.java
new file mode 100644
index 0000000..c5c1d40
--- /dev/null
+++ b/src/main/java/com/byteskeptical/credcat/util/Checks.java
@@ -0,0 +1,123 @@
+package com.byteskeptical.credcat.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.IOException;
+import java.util.Base64;
+import java.util.Collection;
+
+/**
+ * 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
+ * right default for config values, request fields, identifiers.
+ * - {@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
+ * value in one pass. The right tool for property reads from
+ * hand-edited files where trailing whitespace is a real risk.
+ *
+ *
+ * The {@code is*} methods are pure predicates. {@code trimToNull} is the
+ * one that produces a cleaned value.
+ */
+public final class Checks {
+
+ private Checks() {}
+
+ /**
+ * Validates a string as standard base64, surrounding whitespace is
+ * tolerated.
+ *
+ * @param s The string to check.
+ * @return {@code true} if {@code s} decodes cleanly as base64.
+ */
+ public static boolean isBase64(String s) {
+ if (s == null) {
+ return false;
+ }
+
+ String t = s.trim();
+
+ if (t.isEmpty()) {
+ return false;
+ }
+
+ try {
+ Base64.getDecoder().decode(t);
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Validates a string as parseable JSON. Defers entirely to Jackson.
+ * Accepts any well-formed JSON value (object, array, scalar, etc.).
+ *
+ * @param s The string to check.
+ * @return {@code true} if {@code s} is well-formed JSON.
+ */
+ public static boolean isJson(String s) {
+ if (s == null) {
+ return false;
+ }
+
+ try {
+ JsonNode node = JsonHandler.getObjectMapper().readTree(s);
+ return node != null && !node.isMissingNode();
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Whitespace-tolerant emptiness check. "Missing", "empty", and
+ * "whitespace-only" all collapse to {@code true}.
+ *
+ * @param s The string to check.
+ * @return {@code true} if {@code s} is {@code null}, zero-length, or
+ * whitespace only.
+ */
+ public static boolean isNullOrBlank(String s) {
+ return s == null || s.isBlank();
+ }
+
+ /**
+ * Null-tolerant emptiness check for any {@link Collection}. Saves the
+ * {@code c == null || c.isEmpty()} pair from being inlined at every
+ * call site that handles an optional list, set, or map values view.
+ *
+ * @param c The collection to check; may be {@code null}.
+ * @return {@code true} if {@code c} is {@code null} or has zero elements.
+ */
+ public static boolean isNullOrEmpty(Collection> c) {
+ return c == null || c.isEmpty();
+ }
+
+ /**
+ * Normalizes raw input into one of two states: {@code null} (for
+ * missing/empty/whitespace) or the trimmed value. Downstream code only
+ * has to null-check rather than worry about empty strings or stray
+ * whitespace.
+ *
+ * {@link #isNullOrBlank} can't replace this: it only tells you whether
+ * the string is missing; it doesn't give you back the cleaned value.
+ *
+ * @param s The string to normalize.
+ * @return The trimmed value, or {@code null} if the input was null, empty,
+ * or whitespace only.
+ */
+ public static String trimToNull(String s) {
+ if (s == null) {
+ return null;
+ }
+
+ String t = s.trim();
+
+ return t.isEmpty() ? null : t;
+ }
+}
diff --git a/src/main/java/com/byteskeptical/credcat/util/JsonHandler.java b/src/main/java/com/byteskeptical/credcat/util/JsonHandler.java
index 891e454..7a627d3 100644
--- a/src/main/java/com/byteskeptical/credcat/util/JsonHandler.java
+++ b/src/main/java/com/byteskeptical/credcat/util/JsonHandler.java
@@ -1,35 +1,91 @@
package com.byteskeptical.credcat.util;
-
+
+import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Map;
/**
* The (Serial) Transporter aka JSON Statham.
*/
public class JsonHandler {
- private static final ObjectMapper objectMapper = new ObjectMapper();
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
+ .setSerializationInclusion(JsonInclude.Include.NON_NULL)
+ .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ /** No instance, no problem. */
+ private JsonHandler() {}
/**
- * Converts a Java object to a pretty-printed JSON string.
+ * Exposes the shared, pre-configured ObjectMapper for callers that need
+ * Jackson's streaming or tree APIs directly.
*
- * @param obj The object to serialize to JSON.
- * @return The JSON string representation of the object.
- * @throws JsonProcessingException if the object cannot be processed as JSON.
+ * @return The shared, thread-safe ObjectMapper instance.
+ */
+ public static ObjectMapper getObjectMapper() {
+ return OBJECT_MAPPER;
+ }
+
+ /**
+ * Serializes an object to pretty-printed JSON.
+ *
+ * @param obj The object to serialize.
+ * @return The JSON string representation.
+ * @throws JsonProcessingException if {@code obj} cannot be processed as JSON.
*/
public static String toJson(Object obj) throws JsonProcessingException {
- return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(obj);
+ return OBJECT_MAPPER.writerWithDefaultPrettyPrinter()
+ .writeValueAsString(obj);
+ }
+
+ /**
+ * Streams an object as JSON straight to an output stream, sidestepping
+ * the intermediate String allocation that {@link #toJson(Object)} requires.
+ *
+ * @param obj The object to serialize.
+ * @param out The destination stream. Not closed by this method.
+ * @throws IOException if writing fails.
+ */
+ public static void writeJson(Object obj, OutputStream out) throws IOException {
+ OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValue(out, obj);
+ }
+
+ /**
+ * Builds a single-key JSON object as a string. For small status envelopes
+ * (errors, version banners, etc.) where the caller would rather not deal
+ * with a checked exception for input that's trivially serializable.
+ *
+ * @param key The JSON property name. Must not be {@code null}.
+ * @param value The value. {@code null} is rendered as an empty string so
+ * the output is always valid, parseable JSON.
+ * @return A JSON object like {@code {"key" : "value"}}.
+ */
+ public static String envelope(String key, String value) {
+ try {
+ return toJson(Map.of(key, value != null ? value : ""));
+ } catch (JsonProcessingException e) {
+ // A two-string Map can't actually fail. Fall back to a parseable
+ // empty object if it somehow does.
+ return "{}";
+ }
}
/**
* Deserializes a JSON string into a Java object of a specified class.
*
- * @param The type of the object to deserialize to.
- * @param json The JSON string to deserialize.
+ * @param The type of the object to deserialize to.
+ * @param json The JSON string to deserialize.
* @param clazz The class of the object to return.
- * @return An object of type T populated with data from the JSON string.
- * @throws JsonProcessingException if the JSON string is malformed or cannot be processed.
+ * @return An object of type T populated from the JSON.
+ * @throws JsonProcessingException if the JSON is malformed.
*/
public static T fromJson(String json, Class clazz) throws JsonProcessingException {
- return objectMapper.readValue(json, clazz);
+ return OBJECT_MAPPER.readValue(json, clazz);
}
}
diff --git a/src/main/resources/credcat.properties b/src/main/resources/credcat.properties
index 809be42..5f86daa 100644
--- a/src/main/resources/credcat.properties
+++ b/src/main/resources/credcat.properties
@@ -1,16 +1,66 @@
+# =============
# Keeper Config
+# =============
-# Optional: One time passcode used as client identification, allowing dynamic KSM config creation.
+# Optional: One time passcode used as client identification, allowing dynamic
+# KSM config creation.
#keeper.client_key=
-# Optional: Serves as the default KSM device auth if set.
-keeper.config=
+# Optional: Serves as the default KSM device auth when no config or configName
+# is supplied in the request. Accepts any of:
+# - 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
+
+# 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.
+#keeper.config.dir=/etc/credcat/configs
+
+# Optional: Environment variable prefix for named configs. Looking up
+# "configName": "prod" with prefix "KEEPER_CONFIG_" reads KEEPER_CONFIG_PROD.
+# The recommended approach on managed platforms where env vars are the safest
+# secret-injection mechanism available.
+#keeper.config.env=KEEPER_CONFIG_
+
+# Optional: Use LocalConfigStorage instead of InMemoryStorage to persists
+# refreshed tokens to disk. Only enable when the filesystem is writable and the
+# resolved config came from an actual file path. Default: false.
+#keeper.storage.persistent=false
+
+# =============
+# File Handling
+# =============
+
+# Recursively delete the files directory at shutdown. We don't want to leave
+# sensitive remnants around. User-specified keeper.files definition should be
+# mindful when choosing their save location or disable this. Default: true.
+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
+file.transport=INLINE
# Optional: Defaults to OS temp directory:
# Windows: %USERPROFILE%\AppData\Local\Temp\credcat_*
# Linux/Mac: /tmp/credcat_*
#keeper.files=
-# Network Bind
+# ================
+# Network / Server
+# ================
+
+# Network bind. Behind a reverse proxy? Leave host as 127.0.0.1.
server.host=127.0.0.1
server.port=8888
+
+# Fixed thread pool size. Default: max(8, availableProcessors * 2).
+#server.threads=16
+
+# Reject request bodies larger than this many bytes with HTTP 413.
+# Default 1 MiB.
+#server.max_request_bytes=1048576
diff --git a/src/main/resources/logging.properties b/src/main/resources/logging.properties
index a9106ef..0e55172 100644
--- a/src/main/resources/logging.properties
+++ b/src/main/resources/logging.properties
@@ -2,14 +2,15 @@
.level=INFO
# package level
-com.byteskeptical.keeper.level=ALL
+com.byteskeptical.credcat.level=ALL
# console output
handlers=java.util.logging.ConsoleHandler
# console configuration
-java.util.logging.ConsoleHandler.level=ALL
+java.util.logging.ConsoleHandler.encoding=UTF-8
java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter
+java.util.logging.ConsoleHandler.level=ALL
# one formatter to rule them all
java.util.logging.SimpleFormatter.format=%1$tF %1$tT %4$s %2$s: %5$s%6$s%n
diff --git a/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java b/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java
index 5a34e53..2c10c13 100644
--- a/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java
+++ b/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java
@@ -1,6 +1,15 @@
package com.byteskeptical.credcat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
import com.byteskeptical.credcat.SecretsService.AppConfig;
+import com.byteskeptical.credcat.file.FileHandler;
+import com.byteskeptical.credcat.file.FileTransport;
import com.keepersecurity.secretsManager.core.BankAccount;
import com.keepersecurity.secretsManager.core.BankAccounts;
import com.keepersecurity.secretsManager.core.KeeperRecord;
@@ -9,6 +18,11 @@
import com.keepersecurity.secretsManager.core.Login;
import com.keepersecurity.secretsManager.core.Password;
import com.keepersecurity.secretsManager.core.Url;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -19,34 +33,12 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import java.io.IOException;
-import java.lang.reflect.Field;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Stream;
-import java.util.UUID;
-
-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.assertTrue;
-import static org.mockito.Mockito.lenient;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-
+/**
+ * Unit tests for {@link SecretsService} record/field extraction and versioning.
+ */
@ExtendWith(MockitoExtension.class)
class SecretsServiceTest {
- private static final String osTemp = System.getProperty("java.io.tmpdir");
- private static final String keeperDir = "credcat_" + UUID.randomUUID().toString();
-
- private static final String TEST_CLIENT_KEY = "7dae669a419ee250d0fd0e12d527f5f1";
- private static final String TEST_KEEPER_CONFIG = "base64-json-secret";
- private static final String TEST_SAVE_PATH = Path.of(osTemp, keeperDir).toString();
private static final String TEST_UID = "7bN_ceW-p3_alVUNmI09Tw";
private static final String TEST_TITLE = "Credcat Record";
@@ -55,56 +47,46 @@ class SecretsServiceTest {
@Mock private KeeperRecordData mockRecordData;
private SecretsService service;
+ private FileHandler fileHandler;
@BeforeEach
- void setUp() throws Exception {
+ void setUp() throws IOException {
service = new SecretsService(mockConfig);
- confInjection(mockConfig, "filesLocation", TEST_SAVE_PATH);
- confInjection(mockConfig, "clientKey", TEST_CLIENT_KEY);
- confInjection(mockConfig, "keeperConfig", TEST_KEEPER_CONFIG);
-
+ // NONE transport touches no disk -- ideal for record/field unit tests.
+ fileHandler = FileHandler.forTransport(FileTransport.NONE, null);
+
lenient().when(mockRecord.getData()).thenReturn(mockRecordData);
lenient().when(mockRecord.getRecordUid()).thenReturn(TEST_UID);
lenient().when(mockRecordData.getCustom()).thenReturn(Collections.emptyList());
lenient().when(mockRecordData.getFields()).thenReturn(Collections.emptyList());
}
- @DisplayName("Version Check: Return the semantic version")
+ @DisplayName("Version Check: Reports a semantic version")
@Test
- void getVersion_returnsCorrectVersion() {
- assertEquals("1.0.0", service.getVersion(), "Version should match the defined string.");
- }
+ void getVersion_isSemantic() {
+ String version = service.getVersion();
- @DisplayName("Null or Empty Check: Input validation")
- @Test
- void isNullOrEmpty_returnsCorrectBoolean() {
- assertTrue(SecretsService.isNullOrEmpty(null),
- "Should return true for null string."
- );
- assertTrue(SecretsService.isNullOrEmpty(""),
- "Should return true for empty string."
- );
- assertFalse(SecretsService.isNullOrEmpty(" "),
- "Should return false for a string with spaces."
- );
- assertFalse(SecretsService.isNullOrEmpty("text"),
- "Should return false for a non-empty string."
+ assertNotNull(version, "Version should never be null.");
+ assertTrue(
+ version.matches("\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?"),
+ "Version should be semantic (MAJOR.MINOR.PATCH), got: " + version
);
}
@DisplayName("Process Records: Empty list returns empty map")
@Test
- void processRecords_withEmptyList_returnsEmptyMap() throws IOException {
+ void processRecords_withEmptyList_returnsEmptyMap() {
Map> result = service.processRecords(
- Collections.emptyList(), TEST_SAVE_PATH
+ Collections.emptyList(), fileHandler
);
+
assertTrue(result.isEmpty(), "Empty input begets empty map.");
}
@DisplayName("Process Records: Standard fields mapping")
@Test
- void processRecords_withFields_mapsCorrectly() throws IOException {
+ void processRecords_withFields_mapsCorrectly() {
when(mockRecord.getTitle()).thenReturn(TEST_TITLE);
when(mockRecord.getType()).thenReturn("login");
@@ -119,17 +101,18 @@ void processRecords_withFields_mapsCorrectly() throws IOException {
when(mockRecordData.getFields()).thenReturn(List.of(login, password));
Map> result = service.processRecords(
- List.of(mockRecord), TEST_SAVE_PATH
+ List.of(mockRecord), fileHandler
);
assertNotNull(result.get(TEST_UID));
Map details = result.get(TEST_UID);
-
+
assertEquals(TEST_TITLE, details.get("title"));
assertEquals("login", details.get("type"));
@SuppressWarnings("unchecked")
- Map> fields = (Map>) details.get("fields");
+ Map> fields =
+ (Map>) details.get("fields");
assertEquals("admin", fields.get("username").get(0));
assertEquals("123456", fields.get("password").get(0));
@@ -137,7 +120,7 @@ void processRecords_withFields_mapsCorrectly() throws IOException {
@DisplayName("Process Records: Null label uses class name fallback")
@Test
- void processRecords_nullLabel_usesClassName() throws IOException {
+ void processRecords_nullLabel_usesClassName() {
Url urlField = mock(Url.class);
when(urlField.getLabel()).thenReturn(null);
when(urlField.getValue()).thenReturn(List.of("https://example.com"));
@@ -145,12 +128,13 @@ void processRecords_nullLabel_usesClassName() throws IOException {
when(mockRecordData.getFields()).thenReturn(List.of(urlField));
Map> result = service.processRecords(
- List.of(mockRecord), TEST_SAVE_PATH
+ List.of(mockRecord), fileHandler
);
@SuppressWarnings("unchecked")
- Map> fields = (Map>) result.get(TEST_UID).get("fields");
-
+ Map> fields =
+ (Map>) result.get(TEST_UID).get("fields");
+
assertTrue(fields.containsKey("url"),
"Fallback to simple class name on null label"
);
@@ -166,33 +150,37 @@ void xtraxField_mapsCorrectly(
KeeperRecordField field
) {
List fieldValue = service.xtraxField(field);
+
assertEquals(expectedValue, fieldValue,
"Failed to extract values for " + testName
);
}
/**
- * Data provider for parameterized test.
+ * Data provider for the parameterized test.
* (TestName, ExpectedOutput, MockField)
+ *
+ * Stubs are lenient: every mock is built up front but each test
+ * invocation exercises only one, so strict stubbing would flag the rest.
*/
private static Stream fieldScenarios() {
Login login = mock(Login.class);
- when(login.getValue()).thenReturn(List.of("user1"));
+ lenient().when(login.getValue()).thenReturn(List.of("user1"));
Url url = mock(Url.class);
- when(url.getValue()).thenReturn(List.of("http://localhost"));
+ lenient().when(url.getValue()).thenReturn(List.of("http://localhost"));
BankAccounts bankField = mock(BankAccounts.class);
BankAccount bankData = mock(BankAccount.class);
- when(bankData.getAccountType()).thenReturn("Checking");
- when(bankData.getRoutingNumber()).thenReturn("123");
- when(bankData.getAccountNumber()).thenReturn("456");
- when(bankData.getOtherType()).thenReturn("N/A");
- when(bankField.getValue()).thenReturn(List.of(bankData));
+ lenient().when(bankData.getAccountType()).thenReturn("Checking");
+ lenient().when(bankData.getRoutingNumber()).thenReturn("123");
+ lenient().when(bankData.getAccountNumber()).thenReturn("456");
+ lenient().when(bankData.getOtherType()).thenReturn("N/A");
+ lenient().when(bankField.getValue()).thenReturn(List.of(bankData));
String bankString = "Type: Checking, Routing: 123, Account: 456, Other: N/A";
- // Unknown Type
+ // Unknown type, no stubs needed, Falls through to the empty list.
KeeperRecordField unknown = mock(KeeperRecordField.class);
return Stream.of(
@@ -203,16 +191,4 @@ private static Stream fieldScenarios() {
);
}
- /**
- * Inject dependencies into the AppConfig mock.
- * Avoids reading from the disk or creating constructor chains for testing.
- */
- private void confInjection(
- Object target, String fieldName, Object value
- ) throws Exception {
- Field field = target.getClass().getDeclaredField(fieldName);
- field.setAccessible(true);
- field.set(target, value);
- }
-
}
diff --git a/src/test/java/com/byteskeptical/credcat/util/ChecksTest.java b/src/test/java/com/byteskeptical/credcat/util/ChecksTest.java
new file mode 100755
index 0000000..ab02dfc
--- /dev/null
+++ b/src/test/java/com/byteskeptical/credcat/util/ChecksTest.java
@@ -0,0 +1,61 @@
+package com.byteskeptical.credcat.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Collections;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for the {@link Checks} value predicates and normalizer.
+ */
+class ChecksTest {
+
+ @DisplayName("isNullOrBlank: null, empty, and whitespace count as blank")
+ @Test
+ void isNullOrBlank_treatsWhitespaceAsBlank() {
+ assertTrue(Checks.isNullOrBlank(null));
+ assertTrue(Checks.isNullOrBlank(""));
+ assertTrue(Checks.isNullOrBlank(" "));
+ assertFalse(Checks.isNullOrBlank("x"));
+ }
+
+ @DisplayName("isNullOrEmpty: null and empty collections")
+ @Test
+ void isNullOrEmpty_handlesNullAndEmpty() {
+ assertTrue(Checks.isNullOrEmpty(null));
+ assertTrue(Checks.isNullOrEmpty(Collections.emptyList()));
+ assertFalse(Checks.isNullOrEmpty(List.of("x")));
+ }
+
+ @DisplayName("trimToNull: trims, nulls out blanks")
+ @Test
+ void trimToNull_normalizes() {
+ assertNull(Checks.trimToNull(null));
+ assertNull(Checks.trimToNull(" "));
+ assertEquals("x", Checks.trimToNull(" x "));
+ }
+
+ @DisplayName("isBase64: accepts valid base64, rejects junk")
+ @Test
+ void isBase64_validatesEncoding() {
+ assertTrue(Checks.isBase64("YWJj"));
+ assertFalse(Checks.isBase64("not base64!"));
+ assertFalse(Checks.isBase64(""));
+ assertFalse(Checks.isBase64(null));
+ }
+
+ @DisplayName("isJson: accepts valid JSON, rejects junk")
+ @Test
+ void isJson_validatesJson() {
+ assertTrue(Checks.isJson("{\"a\":1}"));
+ assertTrue(Checks.isJson("[1,2,3]"));
+ assertFalse(Checks.isJson("{bad"));
+ assertFalse(Checks.isJson(""));
+ assertFalse(Checks.isJson(null));
+ }
+}
From 8c6f71765f47293b82bd65f56a7d5ce213e26fea Mon Sep 17 00:00:00 2001
From: byteskeptical <40208858+byteskeptical@users.noreply.github.com>
Date: Fri, 5 Jun 2026 04:07:01 +0000
Subject: [PATCH 2/4] updating file permissions
---
src/test/java/com/byteskeptical/credcat/util/ChecksTest.java | 0
1 file changed, 0 insertions(+), 0 deletions(-)
mode change 100755 => 100644 src/test/java/com/byteskeptical/credcat/util/ChecksTest.java
diff --git a/src/test/java/com/byteskeptical/credcat/util/ChecksTest.java b/src/test/java/com/byteskeptical/credcat/util/ChecksTest.java
old mode 100755
new mode 100644
From bb78de74e4120c67e6ebed5ee5078d7b91a10606 Mon Sep 17 00:00:00 2001
From: byteskeptical <40208858+byteskeptical@users.noreply.github.com>
Date: Fri, 5 Jun 2026 04:21:43 +0000
Subject: [PATCH 3/4] wording touch ups and version bump
---
src/main/resources/credcat.properties | 2 +-
.../java/com/byteskeptical/credcat/SecretsServiceTest.java | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/main/resources/credcat.properties b/src/main/resources/credcat.properties
index 5f86daa..636042b 100644
--- a/src/main/resources/credcat.properties
+++ b/src/main/resources/credcat.properties
@@ -11,7 +11,7 @@
# - 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
+#keeper.config=/etc/credcat/configs/devops.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".
diff --git a/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java b/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java
index 2c10c13..50f59e2 100644
--- a/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java
+++ b/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java
@@ -53,7 +53,7 @@ class SecretsServiceTest {
void setUp() throws IOException {
service = new SecretsService(mockConfig);
- // NONE transport touches no disk -- ideal for record/field unit tests.
+ // NONE transport touches no disk, perfect for unit tests.
fileHandler = FileHandler.forTransport(FileTransport.NONE, null);
lenient().when(mockRecord.getData()).thenReturn(mockRecordData);
@@ -157,7 +157,7 @@ void xtraxField_mapsCorrectly(
}
/**
- * Data provider for the parameterized test.
+ * Data provider for parameterized test.
* (TestName, ExpectedOutput, MockField)
*
* Stubs are lenient: every mock is built up front but each test
@@ -180,7 +180,7 @@ private static Stream fieldScenarios() {
String bankString = "Type: Checking, Routing: 123, Account: 456, Other: N/A";
- // Unknown type, no stubs needed, Falls through to the empty list.
+ // Unknown type, no stubs needed, falls through to the empty list.
KeeperRecordField unknown = mock(KeeperRecordField.class);
return Stream.of(
From 53354fbb2aac80dc26c9afc2582e92daefaa6314 Mon Sep 17 00:00:00 2001
From: byteskeptical <40208858+byteskeptical@users.noreply.github.com>
Date: Fri, 5 Jun 2026 04:25:42 +0000
Subject: [PATCH 4/4] setup-java is only at v5 not v6 duh
---
.github/workflows/publish.yml | 4 ++--
.github/workflows/test.yml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 3b8ee87..bd24261 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Setup Repository
- uses: actions/setup-java@v6
+ uses: actions/setup-java@v5
with:
cache: 'maven'
check-latest: true
@@ -37,7 +37,7 @@ jobs:
CENTRAL_USERNAME: ${{ secrets.OSSRH_USERNAME }}
GPG_KEY_PASS: ${{ secrets.GPG_KEY_PASS }}
- name: Publish to GitHub Packages
- uses: actions/setup-java@v6
+ uses: actions/setup-java@v5
with:
cache: 'maven'
check-latest: true
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index dd14721..b156200 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -32,7 +32,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up Java ${{ matrix.java-distribution }} ${{ matrix.java-version }}
- uses: actions/setup-java@v6
+ uses: actions/setup-java@v5
with:
check-latest: true
distribution: ${{ matrix.java-distribution }}