diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c71f4e..15dd6ed 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,25 +17,38 @@ jobs: os: [ubuntu-latest] steps: - uses: actions/checkout@v4 - - name: Set up Java Maven Central Repository + - name: Setup Repository uses: actions/setup-java@v4 with: - java-version: '21' + cache: 'maven' + check-latest: true distribution: 'temurin' + gpg-passphrase: GPG_KEY_PASS + gpg-private-key: ${{ secrets.GPG_KEY }} + java-package: 'jdk' + java-version: '21' server-id: ossrh - server-username: MAVEN_USERNAME - server-password: MAVEN_PASSWORD + server-password: OSSRH_PASSWORD + server-username: OSSRH_USERNAME - name: Publish to the Maven Central Repository - run: mvn --batch-mode -P ossrh deploy + run: mvn -B -P ossrh -U deploy env: - MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} - MAVEN_PASSWORD: ${{ secrets.OSSRH_TOKEN }} - - name: Set up Java GitHub Packages + 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 with: - java-version: '21' + cache: 'maven' + check-latest: true distribution: 'temurin' + gpg-passphrase: GPG_KEY_PASS + gpg-private-key: ${{ secrets.GPG_KEY }} + java-package: 'jdk' + java-version: '21' + server-id: github - name: Publish to GitHub Packages - run: mvn --batch-mode deploy + run: mvn -B -U deploy env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_KEY_PASS: ${{ secrets.GPG_KEY_PASS }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d9464a3..1fffc0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,22 +12,16 @@ jobs: strategy: fail-fast: false matrix: - java-distribution: ['corretto', 'dragonwell', 'jetbrains', 'liberica', 'microsoft', 'oracle', 'sapmachine', 'semeru', 'zulu'] + java-distribution: ['corretto', 'dragonwell', 'liberica', 'microsoft', 'oracle', 'sapmachine', 'semeru', 'zulu'] java-version: ['17', '21'] - os: [macos-13, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] + os: [macos-15-intel, macos-latest, ubuntu-22.04, ubuntu-latest, windows-latest] exclude: - - os: macos-13 + - os: macos-15-intel java-distribution: 'dragonwell' java-version: '17' - - os: macos-13 - java-distribution: 'jetbrains' - java-version: '17' - - os: macos-13 + - os: macos-15-intel java-distribution: 'dragonwell' java-version: '21' - - os: macos-13 - java-distribution: 'jetbrains' - java-version: '21' - os: macos-latest java-distribution: 'dragonwell' java-version: '17' diff --git a/README.md b/README.md index d7722f6..99fa5a6 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ packages like maven will be needed to utilize the provided pom file. Move-Item -Destination $maven_home -Path "$parentDir\*" -Force [Environment]::SetEnvironmentVariable('M2_HOME', $maven_home, [System.EnvironmentVariableTarget]::User) [Environment]::SetEnvironmentVariable('MAVEN_HOME', $maven_home, [System.EnvironmentVariableTarget]::User) - [Environment]::SetEnvironmentVariable('Path', "$env:PATH;$maven_home\bin", [System.EnvironmentVariableTarget]::User) + [Environment]::SetEnvironmentVariable('PATH', "$env:PATH;$maven_home\bin", [System.EnvironmentVariableTarget]::User) Remove-Item "$env:USERPROFILE\Downloads\jdk-21.msi" Remove-Item "$env:USERPROFILE\Downloads\maven.zip" Remove-Item "$env:USERPROFILE\Downloads\maven" -Recurse -Force @@ -165,7 +165,7 @@ packages like maven will be needed to utilize the provided pom file. mvn install # prepare package for official release - mvn release + mvn package ``` 3. Run tests, (optional). Making changes, (required) ```sh @@ -183,22 +183,23 @@ packages like maven will be needed to utilize the provided pom file. ## Usage -You will need to generate a base64 device config for your KSM application folder -or use one for an existing authorized device. The local path location to this -file can be passed as a means to switch between application vaults. You can pass +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. ```sh - Usage: java -jar credcat.jar '{ "config": "config.base64", "titles": ["RECORD_TITLE"], "uids": ["RECORD_UID"] }' + Usage: java -jar credcat.jar [ -server | '{ "config": ".keeper/config.base64", "titles": ["RECORD_TITLE"], "uids": ["RECORD_UID"] }' ] ``` 1. Payload can be any of the following. ```sh ADVANCED='{ "clientKey": "7dae669a419ee250d0fd0e12d527f5f1", "config": "config.base64", "saveLocation": "/mnt/share/keeper", "titles": ["development ldap"], "uids": ["chnmFhEC38YCHhNY1pA8Vg"] }' - TITLE_ONLY='{ "config": "config.base64", "titles": ["Production ClickToCall API Key", "development ldap"] }' - UID_ONLY='{ "config": "config.base64", "uids": ["7bN_ceW-p3_alVUNmI09Tw", "chnmGhEC39YCHhNy1pA8vg"] }' + 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"] }' ``` 2. Whether passing title or uid, records are returned nested under its respective uid. @@ -238,6 +239,17 @@ location is returned in the response. } ``` +3. Running in server mode accepts the same request payload, passed by the http client of your choice. + You can set your preferred host and port in the credcat properties file. + ```sh + java -cp "target/classes:target/dependency/*" -server + java -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 + ``` + [![Product Name Screen Shot][product-screenshot]](https://github.com/byteskeptical/credcat) @@ -249,9 +261,10 @@ location is returned in the response. ## Roadmap +- [x] Handle all field types including files & notes - [x] Handle title & uid searches - [x] Retrieve more than one record in a single request -- [x] Handle all field types including files & notes +- [x] Support stand-alone and server modes See the [open issues](https://github.com/byteskeptical/credcat/issues) for a full list of proposed features (and known issues). @@ -296,7 +309,7 @@ Distributed under the project_license. See `LICENSE` for more information. ## Contact -byteskeptical - [@byteskeptical](https://github.com/byteskeptical) - bugs@byteskeptical.com +byteskeptical - [@byteskeptical](https://github.com/byteskeptical) - bug@byteskeptical.com Project Link: [https://github.com/byteskeptical/credcat](https://github.com/byteskeptical/credcat) @@ -307,7 +320,7 @@ Project Link: [https://github.com/byteskeptical/credcat](https://github.com/byte ## Acknowledgments -* [@byteskeptical](bugs@byteskeptical.com) +* [@byteskeptical](bug@byteskeptical.com)

(back to top)

diff --git a/pom.xml b/pom.xml index d52a08e..f83bd3b 100644 --- a/pom.xml +++ b/pom.xml @@ -74,8 +74,56 @@ 3.8.1 + + maven-gpg-plugin + + + + + --batch + --pinentry-mode + loopback + + ${gpg.keyname} + ${gpg.keyname} + true + + + sign + + sign-artifacts + verify + + + org.apache.maven.plugins + 1.6 + + maven-shade-plugin + + + + org.bouncycastle:bc-fips + + + com.keepersecurity:secretsManager:* + + + + + *:* + + META-INF/LICENSE + META-INF/MANIFEST.MF + META-INF/NOTICE + META-INF/*.DSA + META-INF/*.RSA + META-INF/*.SF + + + + @@ -95,6 +143,21 @@ 3.6.0 + + maven-jar-plugin + + + + true + dependency/ + ${project.groupId}.credcat.SecretsService + + + + org.apache.maven.plugins + 3.4.2 + + maven-surefire-plugin org.apache.maven.plugins @@ -141,7 +204,7 @@ org.junit.jupiter test - 5.13.4 + 6.0.1 @@ -158,7 +221,14 @@ org.junit.jupiter test - 5.13.4 + 6.0.1 + + + + junit-jupiter-params + org.junit.jupiter + test + 6.0.1 @@ -217,8 +287,15 @@ GitHub Packages https://maven.pkg.${source.host}/${source.account}/${project.artifactId} + + github + https://maven.pkg.${source.host}/${source.account}/${project.artifactId} + github + + github + @@ -227,8 +304,15 @@ Central Repository OSSRH https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + ossrh + + ossrh + diff --git a/src/main/java/com/byteskeptical/credcat/SecretsService.java b/src/main/java/com/byteskeptical/credcat/SecretsService.java index 5142656..622a7ab 100644 --- a/src/main/java/com/byteskeptical/credcat/SecretsService.java +++ b/src/main/java/com/byteskeptical/credcat/SecretsService.java @@ -5,7 +5,6 @@ import com.byteskeptical.credcat.util.JsonHandler; import com.keepersecurity.secretsManager.core.AccountNumber; import com.keepersecurity.secretsManager.core.AddressRef; -import com.keepersecurity.secretsManager.core.Addresses; import com.keepersecurity.secretsManager.core.BankAccounts; import com.keepersecurity.secretsManager.core.BirthDate; import com.keepersecurity.secretsManager.core.CardRef; @@ -38,9 +37,19 @@ import com.keepersecurity.secretsManager.core.SecureNote; import com.keepersecurity.secretsManager.core.SecurityQuestions; import com.keepersecurity.secretsManager.core.Text; +import com.keepersecurity.secretsManager.core.TotpCode; import com.keepersecurity.secretsManager.core.Url; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.Security; @@ -51,7 +60,12 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Properties; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; @@ -63,6 +77,28 @@ */ public class SecretsService { + private static final String CONFIG = "credcat.properties"; + private static final String DOMAIN = "keepersecurity.com"; + private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(); + private static final Logger LOGGER = Logger.getLogger( + SecretsService.class.getName() + ); + + static { + Security.addProvider(new BouncyCastleFipsProvider()); + } + + private final AppConfig appConfig; + + /** + * Initialize configuration. + * + * @param appConfig Tell me all the things you want me to do. + */ + public SecretsService(AppConfig appConfig) { + this.appConfig = appConfig; + } + /** * If you don't know, now you know. * @@ -76,21 +112,84 @@ public String getVersion() { * Checks if a string is null or empty. * * @param str A string to check. - * * @return {@code true} if the string is null or empty, {@code false} otherwise. */ public static boolean isNullOrEmpty(String str) { return str == null || str.isEmpty(); } - static { - Security.addProvider(new BouncyCastleFipsProvider()); - } + /** + * Load properties, provide sane defaults. + */ + static class AppConfig { + final int port; + final String clientKey; + final String filesLocation; + final String host; + final String keeperConfig; + + /** + * Properties from classpath or filesystem, prioritizing filesystem. + * + * @throws IOException if reading properties fails. + */ + AppConfig() throws IOException { + Properties props = new Properties(); + + // Load from Classpath + try (InputStream is = SecretsService.class.getClassLoader() + .getResourceAsStream(CONFIG)) { + if (is != null) { + props.load(is); + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, + "Classpath config " + CONFIG + " could not be read.", e + ); + } + + // Load from Filesystem + File fsConfig = new File(CONFIG); + if (fsConfig.exists() && fsConfig.isFile()) { + try (FileInputStream fis = new FileInputStream(fsConfig)) { + props.load(fis); + LOGGER.log(Level.INFO, + "Loaded configuration from {0}", + fsConfig.getAbsolutePath() + ); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, + "Found the config " + CONFIG + + " but failed to read it. Check the permissions.", e + ); + throw e; + } + } - private static final Logger LOGGER = Logger.getLogger(SecretsService.class.getName()); + String osTemp = System.getProperty("java.io.tmpdir"); + String keeperDir = "credcat_" + UUID.randomUUID().toString(); + String keeperFiles = Path.of(osTemp, keeperDir).toString(); + + this.clientKey = props.getProperty("keeper.client_key", null); + this.filesLocation = props.getProperty("keeper.files", keeperFiles); + this.host = props.getProperty("server.host", "127.0.0.1"); + this.port = Integer.parseInt(props.getProperty("server.port", "8080")); + String keepConf = props.getProperty("keeper.config"); + if (keepConf != null && !keepConf.isBlank()) { + Path configPath = Path.of(keepConf); + if (Files.exists(configPath)) { + this.keeperConfig = Files.readString(configPath); + } else { + this.keeperConfig = keepConf; + } + } else { + this.keeperConfig = null; + } + } + } /** - * Finds KeeperRecords by UIDs and/or titles. + * Find KeeperRecords by UIDs and/or titles. * Uses the optimized UID filter for UIDs and iterates for titles. * * @param options A instance of SecretsManagerOptions to use in the lookup. @@ -98,9 +197,9 @@ public static boolean isNullOrEmpty(String str) { * @param uids A list of strings representing KeeperRecord unique identifiers. * @return A list of KeeperRecords found or an empty list. */ - List findRecords(SecretsManagerOptions options, - List titles, - List uids) { + List findRecords( + SecretsManagerOptions options, List titles, List uids + ) { Set records = new HashSet<>(); try { @@ -116,9 +215,11 @@ List findRecords(SecretsManagerOptions options, KeeperRecord record = recordsByTitle.getSecretByTitle(title); if (record != null) { records.add(record); - LOGGER.log(Level.INFO, "Found record by title: ''{0}''", title); + LOGGER.log(Level.INFO, + "Found record by title: ''{0}''", title); } else { - LOGGER.log(Level.WARNING, "Record with title ''{0}'' not found.", title); + LOGGER.log(Level.WARNING, + "Record with title ''{0}'' not found.", title); } } } @@ -130,54 +231,82 @@ List findRecords(SecretsManagerOptions options, } /** - * Initialize secure storage and retrieve secrets. + * Initialize secure storage then retrieve secrets. * * @param jsonRequest A JSON string representing the KeeperRequest. * @return A JSON string of the found secrets. * @throws Exception if the request fails to process. */ public String getSecrets(String jsonRequest) throws Exception { - SecretsManagerOptions options = null; + LOGGER.log(Level.FINE, jsonRequest); KeeperRequest request = JsonHandler.fromJson(jsonRequest, KeeperRequest.class); - LocalConfigStorage storage = null; + 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; + } + + String clientKey = request.getClientKey(); + if (isNullOrEmpty(clientKey)) { + if (appConfig.clientKey == null) { + clientKey = null; + } else { + clientKey = appConfig.clientKey; + } + } + + String saveLocation = request.getSaveLocation(); + if (isNullOrEmpty(saveLocation)) { + saveLocation = appConfig.filesLocation; + } + + LocalConfigStorage storage = null; try { - storage = new LocalConfigStorage(request.getConfig()); + storage = new LocalConfigStorage(keeperConfig); } catch (Exception e) { - String errorMessage = "Error loading KSM Config. Make sure " - + request.getConfig() - + " contains a valid base64 encoded JSON config."; + String errorMessage = "Loading of KSM vault config failed. Be sure " + + keeperConfig + + " contains a valid base64 encoded string or JSON object."; LOGGER.log(Level.SEVERE, errorMessage, e); throw new RuntimeException(errorMessage, e); } - if (request.getClientKey() != null && !request.getClientKey().isBlank()) { - SecretsManager.initializeStorage(storage, request.getClientKey(), "keepersecurity.com"); + if (clientKey != null) { + SecretsManager.initializeStorage(storage, clientKey, DOMAIN); } - options = new SecretsManagerOptions(storage, SecretsManager::cachingPostFunction); + SecretsManagerOptions options = null; + options = new SecretsManagerOptions( + storage, SecretsManager::cachingPostFunction + ); List foundRecords = findRecords( options, request.getTitles(), request.getUids() ); - List responses = new ArrayList<>(); - Map> records = processRecords(foundRecords); + Map> records = processRecords( + foundRecords, saveLocation + ); return JsonHandler.toJson(records); } /** - * Downloads files attached to a KeeperRecord and returns their metadata info. + * Downloads files attached to a KeeperRecord, provides its metadata. * * @param files A list of KeeperFile entries usually from a KeeperRecord. - * @param saveLocation string representing local path to save directory. + * @param saveLocation Local path to save directory for record's files. * @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 = (saveLocation != null && !saveLocation.isBlank()) - ? Path.of(saveLocation) : Files.createTempDirectory("keeper-"); + List processFiles( + List files, String saveLocation + ) throws IOException { + Path downloadDir = Path.of(saveLocation); if (!Files.exists(downloadDir)) { Files.createDirectories(downloadDir); @@ -206,13 +335,16 @@ List processFiles(List files, } /** - * Process record(s) field(s) values & provide a structured format. + * 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. * @return A hashmap of credential fields and their values. * @throws IOException if a file operation fails during processing. */ - Map> processRecords(List records) throws IOException { + Map> processRecords( + List records, String saveLocation + ) throws IOException { Map> result = new HashMap<>(); for (KeeperRecord record : records) { @@ -229,7 +361,9 @@ Map> processRecords(List records) thro List fields = recordData.getFields(); List customFields = recordData.getCustom(); Stream allFields = Stream.concat(fields.stream(), - customFields.stream()); + customFields.stream() + ); + allFields.forEach(field -> { String label = field.getLabel(); if (label == null) { @@ -241,13 +375,15 @@ Map> processRecords(List records) thro if (values != null && !values.isEmpty() && !isNullOrEmpty(values.get(0))) { fieldsMap.put(label, values); } else { - LOGGER.log(Level.FINE, "Skipped empty field value for field: {0}", label); + LOGGER.log(Level.FINE, + "Skipped empty field value for field: {0}", label + ); } }); List files = record.getFiles(); if (files != null) { - filesList.addAll(processFiles(files, null)); + filesList.addAll(processFiles(files, saveLocation)); } recordDetails.put("fields", fieldsMap); @@ -262,7 +398,7 @@ Map> processRecords(List records) thro } /** - * Extracts the value(s) from a KeeperRecordField & handles needed type conversions. My shame :( + * Extracts the value(s) from a KeeperRecordField to a data type. My shame :( * * @param field A KeeperRecordField to be extracted. * @return A List of string(s) or an empty list for unsupported field types. @@ -276,15 +412,18 @@ List xtraxField(KeeperRecordField field) { return ((AddressRef) field).getValue(); } else if (field instanceof BankAccounts) { return ((BankAccounts) field).getValue().stream() + .filter(Objects::nonNull) .map(ba -> { - if (ba == null) { - return null; - } return String.format("Type: %s, Routing: %s, Account: %s, Other: %s", ba.getAccountType(), ba.getRoutingNumber(), - ba.getAccountNumber(), ba.getOtherType()); + ba.getAccountNumber(), ba.getOtherType() + ); }) + .collect(Collectors.toList()); + } else if (field instanceof BirthDate) { + return ((BirthDate) field).getValue().stream() .filter(Objects::nonNull) + .map(timestamp -> new java.util.Date(timestamp).toString()) .collect(Collectors.toList()); } else if (field instanceof CardRef) { return ((CardRef) field).getValue(); @@ -304,24 +443,19 @@ List xtraxField(KeeperRecordField field) { return ((HiddenField) field).getValue(); } else if (field instanceof Hosts) { return ((Hosts) field).getValue().stream() + .filter(Objects::nonNull) .map(h -> { - if (h == null) { - return null; - } return String.format("%s:%s", h.getHostName(), h.getPort()); }) - .filter(Objects::nonNull) .collect(Collectors.toList()); } else if (field instanceof KeyPairs) { return ((KeyPairs) field).getValue().stream() + .filter(Objects::nonNull) .map(kp -> { - if (kp == null) { - return null; - } return String.format("Public Key: %s, Private Key: %s", - kp.getPublicKey(), kp.getPrivateKey()); + kp.getPrivateKey(), kp.getPublicKey() + ); }) - .filter(Objects::nonNull) .collect(Collectors.toList()); } else if (field instanceof LicenseNumber) { return ((LicenseNumber) field).getValue(); @@ -329,28 +463,33 @@ List xtraxField(KeeperRecordField field) { return ((Multiline) field).getValue(); } else if (field instanceof Names) { return ((Names) field).getValue().stream() + .filter(Objects::nonNull) .map(n -> { - if (n == null) { - return null; - } return String.format("%s %s %s", n.getFirst() != null ? n.getFirst() : "", n.getMiddle() != null ? n.getMiddle() : "", n.getLast() != null ? n.getLast() : "" ).trim(); }) - .filter(Objects::nonNull) .collect(Collectors.toList()); } else if (field instanceof OneTimeCode) { - return ((OneTimeCode) field).getValue(); + 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()); } else if (field instanceof OneTimePassword) { return ((OneTimePassword) field).getValue(); } else if (field instanceof Passkeys) { return ((Passkeys) field).getValue().stream() + .filter(Objects::nonNull) .map(pk -> { - if (pk == null) { - return null; - } return String.format("%s, %s, %s, %s, %s, %s, %s", pk.getCredentialId(), pk.getSignCount(), @@ -361,30 +500,28 @@ List xtraxField(KeeperRecordField field) { pk.getPrivateKey() ); }) - .filter(Objects::nonNull) .collect(Collectors.toList()); } else if (field instanceof Password) { return ((Password) field).getValue(); } else if (field instanceof PaymentCards) { return ((PaymentCards) field).getValue().stream() + .filter(Objects::nonNull) .map(pc -> { - if (pc == null) { - return null; - } return String.format("%s, %s, %s", - pc.getCardNumber(), pc.getCardExpirationDate(), pc.getCardSecurityCode()); + pc.getCardNumber(), + pc.getCardExpirationDate(), + pc.getCardSecurityCode() + ); }) - .filter(Objects::nonNull) .collect(Collectors.toList()); } else if (field instanceof Phones) { return ((Phones) field).getValue().stream() + .filter(Objects::nonNull) .map(p -> { - if (p == null) { - return null; - } - return String.format("%s, %s", p.getType(), p.getNumber()); + return String.format("%s, %s", + p.getType(), p.getNumber() + ); }) - .filter(Objects::nonNull) .collect(Collectors.toList()); } else if (field instanceof PinCode) { return ((PinCode) field).getValue(); @@ -392,14 +529,12 @@ List xtraxField(KeeperRecordField field) { return ((SecureNote) field).getValue(); } else if (field instanceof SecurityQuestions) { return ((SecurityQuestions) field).getValue().stream() + .filter(Objects::nonNull) .map(sq -> { - if (sq == null) { - return null; - } return String.format("%s, %s", - sq.getQuestion(), sq.getAnswer()); + sq.getQuestion(), sq.getAnswer() + ); }) - .filter(Objects::nonNull) .collect(Collectors.toList()); } else if (field instanceof Text) { return ((Text) field).getValue(); @@ -407,7 +542,8 @@ List xtraxField(KeeperRecordField field) { 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(); @@ -415,15 +551,96 @@ List xtraxField(KeeperRecordField field) { } /** - * Main method for standalone testing. + * Don't shoot the messenger. + * + * @param exchange Encapsulation of methods for request received and response. + * @param statusCode HTTP status of choice sent as response. + * @param response What say you back? + */ + private static void sendResponse( + HttpExchange exchange, int statusCode, String response + ) throws IOException { + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + /** + * Handles POST requests for secrets. + */ + static class SecretsHandler implements HttpHandler { + private final SecretsService service; + private final AppConfig appConfig; + + SecretsHandler(SecretsService service, AppConfig appConfig) { + this.service = service; + this.appConfig = appConfig; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + sendResponse(exchange, 405, "{\"error\": \"Method Not Allowed\"}"); + return; + } + + try (InputStream is = exchange.getRequestBody()) { + String request = new String(is.readAllBytes(), 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() + "\"}"); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Internal Error", e); + sendResponse(exchange, 500, + "{\"error\": \"Internal Server Error: " + e.getMessage() + "\"}" + ); + } + } + } + + /** + * Handles GET requests for version. + */ + static class VersionHandler implements HttpHandler { + private final SecretsService service; + + VersionHandler(SecretsService service) { + this.service = service; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + sendResponse(exchange, 405, + "{\"error\": \"Method Not Allowed\"}" + ); + return; + } + + sendResponse(exchange, 200, + "{\"version\": \"" + service.getVersion() + "\"}" + ); + } + } + + /** + * Main method. Mode based on arguments. * - * @param args payload based on KeeperRequests. + * @param args payload based on KeeperRequests or "-server". */ public static void main(String[] args) { + long startTime = System.currentTimeMillis(); + if (args.length == 0) { - System.out.println("Usage: java -jar credpeek.jar ''"); + System.out.println("Usage: java -jar credcat.jar [-server | '']\n"); System.out.println( - "Example: java -jar credpeek.jar '{" + "Example: java -jar credcat.jar '{" + "\"config\":\"config.json\", " + "\"titles\":[\"RECORD_TITLES\"], \"uids\":[\"RECORD_UID\"]}'" ); @@ -432,10 +649,53 @@ public static void main(String[] args) { } try { - SecretsService service = new SecretsService(); - String response = service.getSecrets(args[0]); - LOGGER.log(Level.INFO, "--- Secrets Found ---"); - LOGGER.log(Level.INFO, "{0}", response); + AppConfig config = new AppConfig(); + SecretsService service = new SecretsService(config); + + if (args[0].equals("-server")) { + HttpServer server = HttpServer.create( + new InetSocketAddress(config.host, config.port), 0 + ); + + server.createContext( + "/api/getSecrets", + new SecretsHandler(service, config) + ); + server.createContext( + "/api/version", + new VersionHandler(service) + ); + + server.setExecutor(EXECUTOR); + server.start(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + LOGGER.info("Herding credcat for you..."); + server.stop(2); + EXECUTOR.shutdown(); + + try { + if (!EXECUTOR.awaitTermination(5, TimeUnit.SECONDS)) { + EXECUTOR.shutdownNow(); + } + } catch (InterruptedException e) { + EXECUTOR.shutdownNow(); + } + })); + + LOGGER.log(Level.INFO, + "Credcat started meowing in {0}ms on {1}:{2,number,#}", + new Object[] { + (System.currentTimeMillis() - startTime), + config.host, + config.port + } + ); + } else { + String response = service.getSecrets(args[0]); + LOGGER.log(Level.INFO, "--- Secrets Found ---"); + System.out.println(response); + } } catch (Exception e) { LOGGER.log(Level.SEVERE, "Execution failed in main method.", e); } diff --git a/src/main/resources/credcat.properties b/src/main/resources/credcat.properties new file mode 100644 index 0000000..809be42 --- /dev/null +++ b/src/main/resources/credcat.properties @@ -0,0 +1,16 @@ +# Keeper Config + +# 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: Defaults to OS temp directory: +# Windows: %USERPROFILE%\AppData\Local\Temp\credcat_* +# Linux/Mac: /tmp/credcat_* +#keeper.files= + +# Network Bind +server.host=127.0.0.1 +server.port=8888 diff --git a/src/main/resources/logging.properties b/src/main/resources/logging.properties index 4d05012..a9106ef 100644 --- a/src/main/resources/logging.properties +++ b/src/main/resources/logging.properties @@ -1,10 +1,10 @@ -# global logging level +# global level: SEVERE > WARNING > INFO > CONFIG > FINE > FINEST .level=INFO -# package specific level +# package level com.byteskeptical.keeper.level=ALL -# output to the console +# console output handlers=java.util.logging.ConsoleHandler # console configuration diff --git a/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java b/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java index 306c250..5a34e53 100644 --- a/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java +++ b/src/test/java/com/byteskeptical/credcat/SecretsServiceTest.java @@ -1,5 +1,8 @@ package com.byteskeptical.credcat; +import com.byteskeptical.credcat.SecretsService.AppConfig; +import com.keepersecurity.secretsManager.core.BankAccount; +import com.keepersecurity.secretsManager.core.BankAccounts; import com.keepersecurity.secretsManager.core.KeeperRecord; import com.keepersecurity.secretsManager.core.KeeperRecordData; import com.keepersecurity.secretsManager.core.KeeperRecordField; @@ -7,15 +10,24 @@ import com.keepersecurity.secretsManager.core.Password; import com.keepersecurity.secretsManager.core.Url; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; 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; @@ -29,88 +41,178 @@ @ExtendWith(MockitoExtension.class) class SecretsServiceTest { - @Mock - private KeeperRecord mockRecord; - @Mock - private KeeperRecordData mockRecordData; - @Mock - private Login mockLoginField; - @Mock - private Password mockPasswordField; - @Mock - private Url mockUrlField; + 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"; + + @Mock private AppConfig mockConfig; + @Mock private KeeperRecord mockRecord; + @Mock private KeeperRecordData mockRecordData; private SecretsService service; @BeforeEach - void setUp() { - service = new SecretsService(); + void setUp() throws Exception { + service = new SecretsService(mockConfig); + + confInjection(mockConfig, "filesLocation", TEST_SAVE_PATH); + confInjection(mockConfig, "clientKey", TEST_CLIENT_KEY); + confInjection(mockConfig, "keeperConfig", TEST_KEEPER_CONFIG); + 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") @Test void getVersion_returnsCorrectVersion() { assertEquals("1.0.0", service.getVersion(), "Version should match the defined string."); } + @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."); + 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." + ); } + @DisplayName("Process Records: Empty list returns empty map") @Test void processRecords_withEmptyList_returnsEmptyMap() throws IOException { - Map> result = service.processRecords(Collections.emptyList()); - assertTrue(result.isEmpty(), "Processing an empty list should result in an empty map."); + Map> result = service.processRecords( + Collections.emptyList(), TEST_SAVE_PATH + ); + assertTrue(result.isEmpty(), "Empty input begets empty map."); } + @DisplayName("Process Records: Standard fields mapping") @Test - void processRecords_withVariousFieldTypes_mapsAllCorrectly() throws IOException { - when(mockRecord.getRecordUid()).thenReturn("test-uid-123"); - when(mockRecord.getTitle()).thenReturn("My Test Record"); + void processRecords_withFields_mapsCorrectly() throws IOException { + when(mockRecord.getTitle()).thenReturn(TEST_TITLE); when(mockRecord.getType()).thenReturn("login"); - when(mockLoginField.getValue()).thenReturn(List.of("my-username")); - when(mockLoginField.getLabel()).thenReturn("username"); - when(mockPasswordField.getValue()).thenReturn(List.of("my-secret-password")); - when(mockPasswordField.getLabel()).thenReturn("password"); - when(mockUrlField.getValue()).thenReturn(List.of("https://test.byteskeptical.com")); - when(mockUrlField.getLabel()).thenReturn("url"); + Login login = mock(Login.class); + when(login.getLabel()).thenReturn("username"); + when(login.getValue()).thenReturn(List.of("admin")); - List fields = new ArrayList<>(); - fields.add(mockLoginField); - fields.add(mockPasswordField); - fields.add(mockUrlField); - when(mockRecordData.getFields()).thenReturn(fields); + Password password = mock(Password.class); + when(password.getLabel()).thenReturn("password"); + when(password.getValue()).thenReturn(List.of("123456")); - Map> result = service.processRecords(List.of(mockRecord)); + when(mockRecordData.getFields()).thenReturn(List.of(login, password)); - assertNotNull(result, "Result map should not be null"); - assertTrue(result.containsKey("test-uid-123"), "Result should contain the record UID as a key"); + Map> result = service.processRecords( + List.of(mockRecord), TEST_SAVE_PATH + ); - Map recordDetails = result.get("test-uid-123"); - assertEquals("My Test Record", recordDetails.get("title"), "Title should be correctly mapped"); - assertEquals("login", recordDetails.get("type"), "Type should be correctly mapped"); + 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> mappedFields = (Map>) recordDetails.get("fields"); - assertNotNull(mappedFields, "Fields map should not be null"); - assertEquals("my-username", mappedFields.get("username").get(0), "Username should be correct"); - assertEquals("my-secret-password", mappedFields.get("password").get(0), "Password should be correct"); - assertEquals("https://test.byteskeptical.com", mappedFields.get("url").get(0), "URL should be correct"); + Map> fields = (Map>) details.get("fields"); + + assertEquals("admin", fields.get("username").get(0)); + assertEquals("123456", fields.get("password").get(0)); } + @DisplayName("Process Records: Null label uses class name fallback") @Test - void xtraxField_withUnknownType_returnsEmptyList() { - KeeperRecordField unknownField = mock(KeeperRecordField.class); + void processRecords_nullLabel_usesClassName() throws IOException { + Url urlField = mock(Url.class); + when(urlField.getLabel()).thenReturn(null); + when(urlField.getValue()).thenReturn(List.of("https://example.com")); + + when(mockRecordData.getFields()).thenReturn(List.of(urlField)); + + Map> result = service.processRecords( + List.of(mockRecord), TEST_SAVE_PATH + ); - List result = service.xtraxField(unknownField); + @SuppressWarnings("unchecked") + Map> fields = (Map>) result.get(TEST_UID).get("fields"); + + assertTrue(fields.containsKey("url"), + "Fallback to simple class name on null label" + ); + assertEquals("https://example.com", fields.get("url").get(0)); + } + + @ParameterizedTest(name = "{index} => Field: {0}, Expected: {1}") + @MethodSource("fieldScenarios") + @DisplayName("XtraxField: Extracts values from all SDK types") + void xtraxField_mapsCorrectly( + String testName, + List expectedValue, + KeeperRecordField field + ) { + List fieldValue = service.xtraxField(field); + assertEquals(expectedValue, fieldValue, + "Failed to extract values for " + testName + ); + } - assertNotNull(result, "Result should not be null."); - assertTrue(result.isEmpty(), "Result for an unknown field type should be an empty list."); + /** + * Data provider for parameterized test. + * (TestName, ExpectedOutput, MockField) + */ + private static Stream fieldScenarios() { + Login login = mock(Login.class); + when(login.getValue()).thenReturn(List.of("user1")); + + Url url = mock(Url.class); + 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)); + + String bankString = "Type: Checking, Routing: 123, Account: 456, Other: N/A"; + + // Unknown Type + KeeperRecordField unknown = mock(KeeperRecordField.class); + + return Stream.of( + Arguments.of("BankAccounts", List.of(bankString), bankField), + Arguments.of("Login", List.of("user1"), login), + Arguments.of("Unknown Type", Collections.emptyList(), unknown), + Arguments.of("Url", List.of("http://localhost"), url) + ); } + + /** + * 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); + } + }