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. + * + *
      + *
    1. An explicit literal in the request body ({@code config}) -- treated as + * either a filesystem path or raw base64/JSON content.
    2. + *
    3. A named config in the request body ({@code configName}) -- looked up + * against the configured directory and environment variable prefix + * (in that order).
    4. + *
    5. The service default ({@code keeper.config} from properties).
    6. + *
    + * + *

    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 }}