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);
+ }
+
}